@cryptotaxi247 / CoPilot / commits / ce04e737

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 })