New login route (#535)
* Implement separate login routes for main portal and customer portal * Refactor login logic and enhance error handling in customer portal authentication * Remove deprecated login functions and clean up authentication routes * precommit-fixes
taylor_socfortress committed
Nov 30, 2025 at 14:19 UTC
ce04e7378676b0308437041b2ad90c6f6aea5a3a
4 files changed
+333
-253
backend/app/auth/routes/auth.py
+68
-5
@@ -33,18 +33,58 @@ auth_handler = AuthHandler()
33
34
35
@auth_router.post("/token", response_model=Token)
36
-async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
36
+async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)):
37
"""
38
Authenticates a user and generates an access token.
39
40
Args:
41
form_data (OAuth2PasswordRequestForm): The form data containing the username and password.
42
+ session (AsyncSession): The database session.
43
44
Returns:
45
dict: A dictionary containing the access token and token type.
46
+
47
+ Raises:
48
+ HTTPException: If user is customer_user role trying to access main portal.
49
+ """
50
+ user = await auth_handler.authenticate_user(form_data.username, form_data.password)
51
+ if not user:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_401_UNAUTHORIZED,
54
+ detail="Incorrect username or password",
55
+ headers={"WWW-Authenticate": "Bearer"},
56
+ )
57
+
58
+ # Check if user is customer_user role
59
+ if user.role_id == RoleEnum.customer_user.value:
60
+ logger.warning(f"Customer user {user.username} attempted to log in to main portal")
61
+ raise HTTPException(
62
+ status_code=status.HTTP_403_FORBIDDEN,
63
+ detail="This account is registered for the Customer Portal only. Please log in at the Customer Portal to access your account.",
64
+ headers={"WWW-Authenticate": "Bearer"},
65
+ )
66
+
67
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
68
+ access_token = await auth_handler.encode_token(user.username, access_token_expires)
69
+ logger.info(f"User {user.username} logged in successfully")
70
+ return {"access_token": access_token, "token_type": "bearer"}
71
+
72
+
73
+@auth_router.post("/token/customer-portal", response_model=Token)
74
+async def login_for_customer_portal(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)):
75
"""
46
- # user = auth_handler.authenticate_user(form_data.username, form_data.password)
76
+ Authenticates a customer user and generates an access token for the customer portal.
77
78
+ Args:
79
+ form_data (OAuth2PasswordRequestForm): The form data containing the username and password.
80
+ session (AsyncSession): The database session.
81
+
82
+ Returns:
83
+ dict: A dictionary containing the access token and token type.
84
+
85
+ Raises:
86
+ HTTPException: If user is not a customer_user role.
87
+ """
88
user = await auth_handler.authenticate_user(form_data.username, form_data.password)
89
if not user:
90
raise HTTPException(
@@ -52,9 +92,19 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
92
detail="Incorrect username or password",
93
headers={"WWW-Authenticate": "Bearer"},
94
)
95
+
96
+ # Only allow customer_user role to log in here
97
+ if user.role_id != RoleEnum.customer_user.value:
98
+ logger.warning(f"Non-customer user {user.username} attempted to log in to customer portal")
99
+ raise HTTPException(
100
+ status_code=status.HTTP_403_FORBIDDEN,
101
+ detail="This account does not have access to the Customer Portal. Please log in at the main portal.",
102
+ headers={"WWW-Authenticate": "Bearer"},
103
+ )
104
+
105
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
106
access_token = await auth_handler.encode_token(user.username, access_token_expires)
57
- logger.info(f"Access token: {access_token}")
107
+ logger.info(f"Customer user {user.username} logged in successfully to customer portal")
108
return {"access_token": access_token, "token_type": "bearer"}
109
110
@@ -119,23 +169,36 @@ async def register(user: UserInput, session: AsyncSession = Depends(get_db)):
169
description="Login user",
170
deprecated=True,
171
)
122
-async def login(user: UserLogin):
172
+async def login(user: UserLogin, session: AsyncSession = Depends(get_db)):
173
"""
174
Logs in a user.
175
176
Args:
177
user (UserLogin): The user login credentials.
178
+ session (AsyncSession): The database session.
179
180
Returns:
181
dict: A dictionary containing the authentication token, success status, and a message.
182
+
183
+ Raises:
184
+ HTTPException: If user is customer_user role trying to access main portal.
185
"""
132
- # user_found = find_user(user.username)
186
user_found = await find_user(user.username)
187
if not user_found:
188
raise HTTPException(status_code=401, detail="Invalid username and/or password")
189
+
190
verified = auth_handler.verify_password(user.password, user_found.password)
191
if not verified:
192
raise HTTPException(status_code=401, detail="Invalid username and/or password")
193
+
194
+ # Check if user is customer_user role
195
+ if user_found.role_id == RoleEnum.customer_user.value:
196
+ logger.warning(f"Customer user {user_found.username} attempted to log in to main portal")
197
+ raise HTTPException(
198
+ status_code=status.HTTP_403_FORBIDDEN,
199
+ detail="This account is registered for the Customer Portal only. Please log in at the Customer Portal to access your account.",
200
+ )
201
+
202
token = auth_handler.encode_token(user_found.username)
203
return {"token": token, "success": True, "message": "Login successful"}
204
customer_portal/src/api/auth.ts
+1
-1
@@ -26,7 +26,7 @@ export class AuthAPI {
26
formData.append("username", credentials.username)
27
formData.append("password", credentials.password)
28
29
- const response = await httpClient.post("/auth/token", formData, {
29
+ const response = await httpClient.post("/auth/token/customer-portal", formData, {
30
headers: {
31
"Content-Type": "application/x-www-form-urlencoded"
32
}
customer_portal/src/components/LoginPage.vue
+161
-157
@@ -1,113 +1,113 @@
1
<template>
2
- <div class="flex min-h-screen">
3
- <!-- Left side - Login Form -->
4
- <div class="flex flex-1 items-center justify-center bg-gray-50 px-4 sm:px-6 lg:px-8">
5
- <div class="w-full max-w-md space-y-8">
6
- <!-- Logo and Title -->
7
- <div class="text-center">
8
- <div class="mb-6 min-h-12">
9
- <img
10
- v-if="portalLogo && showLogo"
11
- class="mx-auto h-12 w-auto"
12
- :src="portalLogo"
13
- :alt="portalTitle"
14
- @error="showLogo = false"
15
- />
16
- </div>
17
- <h2 class="mb-2 min-h-10 text-4xl font-bold text-gray-900">{{ portalTitle }}</h2>
18
- <p class="text-lg text-gray-600">Access your security dashboard and reports</p>
19
- </div>
20
-
21
- <!-- Login Form -->
22
- <div class="mt-8 rounded-lg bg-white px-6 py-8 shadow-lg">
23
- <form @submit.prevent="handleLogin" class="space-y-6">
24
- <div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
25
- <div class="text-sm text-red-700">{{ error }}</div>
26
- </div>
27
-
28
- <div>
29
- <label for="username" class="mb-2 block text-sm font-medium text-gray-700">Username</label>
30
- <input
31
- id="username"
32
- v-model="username"
33
- type="text"
34
- required
35
- autocomplete="username"
36
- class="block w-full rounded-md border border-gray-300 px-3 py-3 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
37
- placeholder="Enter your username"
38
- />
39
- </div>
40
-
41
- <div>
42
- <label for="password" class="mb-2 block text-sm font-medium text-gray-700">Password</label>
43
- <input
44
- id="password"
45
- v-model="password"
46
- type="password"
47
- required
48
- autocomplete="current-password"
49
- class="block w-full rounded-md border border-gray-300 px-3 py-3 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
50
- placeholder="Enter your password"
51
- />
52
- </div>
53
-
54
- <div>
55
- <button
56
- type="submit"
57
- :disabled="loading || !username || !password"
58
- class="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-3 text-base font-medium text-white transition-colors duration-200 hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
59
- >
60
- <span v-if="loading" class="flex items-center">
61
- <svg
62
- class="mr-3 -ml-1 h-5 w-5 animate-spin text-white"
63
- xmlns="http://www.w3.org/2000/svg"
64
- fill="none"
65
- viewBox="0 0 24 24"
66
- >
67
- <circle
68
- class="opacity-25"
69
- cx="12"
70
- cy="12"
71
- r="10"
72
- stroke="currentColor"
73
- stroke-width="4"
74
- ></circle>
75
- <path
76
- class="opacity-75"
77
- fill="currentColor"
78
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
79
- ></path>
80
- </svg>
81
- Signing in...
82
- </span>
83
- <span v-else>Sign in</span>
84
- </button>
85
- </div>
86
- </form>
87
- </div>
88
-
89
- <!-- Footer -->
90
- <div class="text-center text-sm text-gray-500">
91
- <p>For customer users only</p>
92
- </div>
93
- </div>
94
- </div>
95
-
96
- <!-- Right side - Background Image/Color -->
97
- <div class="relative hidden flex-1 lg:block">
98
- <div class="absolute inset-0 bg-linear-to-br from-indigo-600 to-purple-700">
99
- <div class="bg-opacity-20 absolute inset-0 bg-black"></div>
100
- <div class="relative flex h-full items-center justify-center p-12">
101
- <div class="text-center text-white">
102
- <h3 class="mb-4 text-3xl font-bold">Welcome to Your Security Dashboard</h3>
103
- <p class="text-xl opacity-90">
104
- Monitor alerts, track cases, and stay informed about your organization's security posture.
105
- </p>
106
- </div>
107
- </div>
108
- </div>
109
- </div>
110
- </div>
2
+ <div class="flex min-h-screen">
3
+ <!-- Left side - Login Form -->
4
+ <div class="flex flex-1 items-center justify-center bg-gray-50 px-4 sm:px-6 lg:px-8">
5
+ <div class="w-full max-w-md space-y-8">
6
+ <!-- Logo and Title -->
7
+ <div class="text-center">
8
+ <div class="mb-6 min-h-12">
9
+ <img
10
+ v-if="portalLogo && showLogo"
11
+ class="mx-auto h-12 w-auto"
12
+ :src="portalLogo"
13
+ :alt="portalTitle"
14
+ @error="showLogo = false"
15
+ />
16
+ </div>
17
+ <h2 class="mb-2 min-h-10 text-4xl font-bold text-gray-900">{{ portalTitle }}</h2>
18
+ <p class="text-lg text-gray-600">Access your security dashboard and reports</p>
19
+ </div>
20
+
21
+ <!-- Login Form -->
22
+ <div class="mt-8 rounded-lg bg-white px-6 py-8 shadow-lg">
23
+ <form @submit.prevent="handleLogin" class="space-y-6">
24
+ <div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
25
+ <div class="text-sm text-red-700">{{ error }}</div>
26
+ </div>
27
+
28
+ <div>
29
+ <label for="username" class="mb-2 block text-sm font-medium text-gray-700">Username</label>
30
+ <input
31
+ id="username"
32
+ v-model="username"
33
+ type="text"
34
+ required
35
+ autocomplete="username"
36
+ class="block w-full rounded-md border border-gray-300 px-3 py-3 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
37
+ placeholder="Enter your username"
38
+ />
39
+ </div>
40
+
41
+ <div>
42
+ <label for="password" class="mb-2 block text-sm font-medium text-gray-700">Password</label>
43
+ <input
44
+ id="password"
45
+ v-model="password"
46
+ type="password"
47
+ required
48
+ autocomplete="current-password"
49
+ class="block w-full rounded-md border border-gray-300 px-3 py-3 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
50
+ placeholder="Enter your password"
51
+ />
52
+ </div>
53
+
54
+ <div>
55
+ <button
56
+ type="submit"
57
+ :disabled="loading || !username || !password"
58
+ class="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-3 text-base font-medium text-white transition-colors duration-200 hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
59
+ >
60
+ <span v-if="loading" class="flex items-center">
61
+ <svg
62
+ class="mr-3 -ml-1 h-5 w-5 animate-spin text-white"
63
+ xmlns="http://www.w3.org/2000/svg"
64
+ fill="none"
65
+ viewBox="0 0 24 24"
66
+ >
67
+ <circle
68
+ class="opacity-25"
69
+ cx="12"
70
+ cy="12"
71
+ r="10"
72
+ stroke="currentColor"
73
+ stroke-width="4"
74
+ ></circle>
75
+ <path
76
+ class="opacity-75"
77
+ fill="currentColor"
78
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
79
+ ></path>
80
+ </svg>
81
+ Signing in...
82
+ </span>
83
+ <span v-else>Sign in</span>
84
+ </button>
85
+ </div>
86
+ </form>
87
+ </div>
88
+
89
+ <!-- Footer -->
90
+ <div class="text-center text-sm text-gray-500">
91
+ <p>For customer users only</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Right side - Background Image/Color -->
97
+ <div class="relative hidden flex-1 lg:block">
98
+ <div class="absolute inset-0 bg-linear-to-br from-indigo-600 to-purple-700">
99
+ <div class="bg-opacity-20 absolute inset-0 bg-black"></div>
100
+ <div class="relative flex h-full items-center justify-center p-12">
101
+ <div class="text-center text-white">
102
+ <h3 class="mb-4 text-3xl font-bold">Welcome to Your Security Dashboard</h3>
103
+ <p class="text-xl opacity-90">
104
+ Monitor alerts, track cases, and stay informed about your organization's security posture.
105
+ </p>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
</template>
112
113
<script setup lang="ts">
@@ -129,53 +129,57 @@ const portalTitle = computed(() => portalSettingsStore.portalTitle)
129
const portalLogo = computed(() => portalSettingsStore.portalLogo)
130
131
const handleLogin = async () => {
132
- loading.value = true
133
- error.value = ""
134
-
135
- try {
136
- const data = await AuthAPI.login({
137
- username: username.value,
138
- password: password.value
139
- })
140
-
141
- if (data.access_token) {
142
- const decoded = AuthAPI.decodeToken(data.access_token)
143
-
144
- if (!decoded) {
145
- error.value = "Invalid token received"
146
- return
147
- }
148
-
149
- // Check if user has customer_user scope
150
- if (AuthAPI.hasCustomerAccess(decoded.scopes)) {
151
- // Store the token and user info
152
- localStorage.setItem("customer-portal-auth-token", data.access_token)
153
- localStorage.setItem(
154
- "customer-portal-user",
155
- JSON.stringify({
156
- username: username.value,
157
- scopes: decoded.scopes
158
- })
159
- )
160
-
161
- router.push("/")
162
- } else {
163
- error.value = "Access denied. Customer portal is for customer users only."
164
- }
165
- } else {
166
- error.value = "Login failed"
167
- }
168
- } catch (err: any) {
169
- if (err.response?.data?.detail) {
170
- error.value = err.response.data.detail
171
- } else if (err.message) {
172
- error.value = err.message
173
- } else {
174
- error.value = "Network error. Please try again."
175
- }
176
- console.error("Login error:", err)
177
- } finally {
178
- loading.value = false
179
- }
132
+ loading.value = true
133
+ error.value = ""
134
+
135
+ try {
136
+ const data = await AuthAPI.login({
137
+ username: username.value,
138
+ password: password.value
139
+ })
140
+
141
+ if (data.access_token) {
142
+ const decoded = AuthAPI.decodeToken(data.access_token)
143
+
144
+ if (!decoded) {
145
+ error.value = "Invalid token received"
146
+ return
147
+ }
148
+
149
+ // Check if user has customer_user scope
150
+ if (AuthAPI.hasCustomerAccess(decoded.scopes)) {
151
+ // Store the token and user info
152
+ localStorage.setItem("customer-portal-auth-token", data.access_token)
153
+ localStorage.setItem(
154
+ "customer-portal-user",
155
+ JSON.stringify({
156
+ username: username.value,
157
+ scopes: decoded.scopes
158
+ })
159
+ )
160
+
161
+ router.push("/")
162
+ } else {
163
+ error.value = "Access denied. Customer portal is for customer users only."
164
+ }
165
+ } else {
166
+ error.value = "Login failed"
167
+ }
168
+ } catch (err: any) {
169
+ console.error("Login error:", err)
170
+
171
+ // Enhanced error message extraction - prioritize message over detail
172
+ if (err.response?.data?.message) {
173
+ error.value = err.response.data.message
174
+ } else if (err.response?.data?.detail) {
175
+ error.value = err.response.data.detail
176
+ } else if (err.message) {
177
+ error.value = err.message
178
+ } else {
179
+ error.value = "Network error. Please try again."
180
+ }
181
+ } finally {
182
+ loading.value = false
183
+ }
184
}
185
</script>
customer_portal/src/stores/auth.ts
+103
-90
@@ -2,100 +2,113 @@ import { defineStore } from "pinia"
2
import axios from "axios"
3
4
interface User {
5
- id: number
6
- username: string
7
- email: string
8
- role_id?: number
9
- role_name?: string
5
+ id: number
6
+ username: string
7
+ email: string
8
+ role_id?: number
9
+ role_name?: string
10
}
11
12
interface AuthState {
13
- userToken: string | null
14
- user: User | null
15
- isAuthenticated: boolean
13
+ userToken: string | null
14
+ user: User | null
15
+ isAuthenticated: boolean
16
}
17
18
export const useAuthStore = defineStore("auth", {
19
- state: (): AuthState => ({
20
- userToken: null,
21
- user: null,
22
- isAuthenticated: false
23
- }),
24
-
25
- getters: {
26
- isLogged: state => state.isAuthenticated && !!state.userToken,
27
- isCustomerUser: state => state.user?.role_name === "customer_user"
28
- },
29
-
30
- actions: {
31
- async login(username: string, password: string) {
32
- try {
33
- const formData = new FormData()
34
- formData.append("username", username)
35
- formData.append("password", password)
36
-
37
- const response = await axios.post("/api/auth/token", formData)
38
-
39
- if (response.data.access_token) {
40
- this.userToken = response.data.access_token
41
- this.isAuthenticated = true
42
- await this.fetchUser()
43
- return { success: true }
44
- }
45
-
46
- return { success: false, message: "Login failed" }
47
- } catch (error: any) {
48
- return {
49
- success: false,
50
- message: error.response?.data?.detail || "Login failed"
51
- }
52
- }
53
- },
54
-
55
- async fetchUser() {
56
- try {
57
- const response = await axios.get("/api/auth/me", {
58
- headers: {
59
- Authorization: `Bearer ${this.userToken}`
60
- }
61
- })
62
- this.user = response.data
63
- } catch (error) {
64
- console.error("Failed to fetch user:", error)
65
- }
66
- },
67
-
68
- async refreshToken() {
69
- try {
70
- const response = await axios.get("/api/auth/refresh", {
71
- headers: {
72
- Authorization: `Bearer ${this.userToken}`
73
- }
74
- })
75
-
76
- if (response.data.access_token) {
77
- this.userToken = response.data.access_token
78
- }
79
- } catch (error) {
80
- console.error("Failed to refresh token:", error)
81
- this.logout()
82
- }
83
- },
84
-
85
- logout() {
86
- this.userToken = null
87
- this.user = null
88
- this.isAuthenticated = false
89
- },
90
-
91
- setLogout() {
92
- this.logout()
93
- }
94
- },
95
-
96
- persist: {
97
- key: "customer-portal-auth",
98
- storage: localStorage,
99
- pick: ["userToken", "user", "isAuthenticated"]
100
- }
19
+ state: (): AuthState => ({
20
+ userToken: null,
21
+ user: null,
22
+ isAuthenticated: false
23
+ }),
24
+
25
+ getters: {
26
+ isLogged: state => state.isAuthenticated && !!state.userToken,
27
+ isCustomerUser: state => state.user?.role_name === "customer_user"
28
+ },
29
+
30
+ actions: {
31
+ async login(username: string, password: string) {
32
+ try {
33
+ const formData = new FormData()
34
+ formData.append("username", username)
35
+ formData.append("password", password)
36
+
37
+ const response = await axios.post("/api/auth/token/customer-portal", formData)
38
+
39
+ if (response.data.access_token) {
40
+ this.userToken = response.data.access_token
41
+ this.isAuthenticated = true
42
+ await this.fetchUser()
43
+ return { success: true }
44
+ }
45
+
46
+ return { success: false, message: "Login failed" }
47
+ } catch (error: any) {
48
+ console.error("Login error:", error.response?.data) // Debug log
49
+
50
+ // Enhanced error message extraction - check message first, then detail
51
+ let errorMessage = "Login failed"
52
+
53
+ if (error.response?.data?.message) {
54
+ errorMessage = error.response.data.message
55
+ } else if (error.response?.data?.detail) {
56
+ errorMessage = error.response.data.detail
57
+ } else if (error.message) {
58
+ errorMessage = error.message
59
+ }
60
+
61
+ return {
62
+ success: false,
63
+ message: errorMessage
64
+ }
65
+ }
66
+ },
67
+
68
+ async fetchUser() {
69
+ try {
70
+ const response = await axios.get("/api/auth/me", {
71
+ headers: {
72
+ Authorization: `Bearer ${this.userToken}`
73
+ }
74
+ })
75
+ this.user = response.data
76
+ } catch (error) {
77
+ console.error("Failed to fetch user:", error)
78
+ }
79
+ },
80
+
81
+ async refreshToken() {
82
+ try {
83
+ const response = await axios.get("/api/auth/refresh", {
84
+ headers: {
85
+ Authorization: `Bearer ${this.userToken}`
86
+ }
87
+ })
88
+
89
+ if (response.data.access_token) {
90
+ this.userToken = response.data.access_token
91
+ }
92
+ } catch (error) {
93
+ console.error("Failed to refresh token:", error)
94
+ this.logout()
95
+ }
96
+ },
97
+
98
+ logout() {
99
+ this.userToken = null
100
+ this.user = null
101
+ this.isAuthenticated = false
102
+ },
103
+
104
+ setLogout() {
105
+ this.logout()
106
+ }
107
+ },
108
+
109
+ persist: {
110
+ key: "customer-portal-auth",
111
+ storage: localStorage,
112
+ pick: ["userToken", "user", "isAuthenticated"]
113
+ }
114
})