-
Notifications
You must be signed in to change notification settings - Fork 1
feat(auth): [#4] Implement route guards for authentication #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b4b7c64
572274f
4a5da1a
1bdcd89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { Injectable } from '@nestjs/common'; | ||
| import { AuthGuard } from '@nestjs/passport'; | ||
|
|
||
| @Injectable() | ||
| export class JwtAuthGuard extends AuthGuard('jwt') {} | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { PassportStrategy } from '@nestjs/passport'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import { Strategy, ExtractJwt } from 'passport-jwt'; | ||
| import { Injectable } from '@nestjs/common'; | ||
|
|
||
| @Injectable() | ||
| export class JwtStrategy extends PassportStrategy(Strategy) { | ||
| constructor(configService: ConfigService) { | ||
| super({ | ||
| jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), | ||
| ignoreExpiration: false, | ||
| secretOrKey: configService.getOrThrow<string>('JWT_SECRET'), | ||
| }); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| validate(payload: { sub: number; email: string }) { | ||
| return { userId: payload.sub, email: payload.email }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,15 @@ | ||
| import { Routes } from '@angular/router'; | ||
| import { Dashboard } from '@/app/pages/dashboard/dashboard'; | ||
| import { authGuard } from '@/app/auth/guards/auth-guard'; | ||
|
|
||
| export const routes: Routes = [ | ||
| { | ||
| path: 'auth', | ||
| loadChildren: () => import('./auth/auth-module').then((m) => m.AuthModule), | ||
| }, | ||
| { | ||
| path: 'dashboard', | ||
| component: Dashboard, | ||
| canActivate: [authGuard], | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { | |||||||||||||||||||||||||||||||||||||||
| import { EMAIL_REGEX_STRING } from '@shared/validation/email.constants'; | ||||||||||||||||||||||||||||||||||||||||
| import { LoginUserDto } from '@shared/dtos/auth/login-user.dto'; | ||||||||||||||||||||||||||||||||||||||||
| import { CommonModule } from '@angular/common'; | ||||||||||||||||||||||||||||||||||||||||
| import { Router } from '@angular/router'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @Component({ | ||||||||||||||||||||||||||||||||||||||||
| selector: 'app-login', | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -18,6 +19,7 @@ import { CommonModule } from '@angular/common'; | |||||||||||||||||||||||||||||||||||||||
| export class Login { | ||||||||||||||||||||||||||||||||||||||||
| private formBuilder = inject(FormBuilder); | ||||||||||||||||||||||||||||||||||||||||
| private authService = inject(Auth); | ||||||||||||||||||||||||||||||||||||||||
| private router = inject(Router); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| loginForm = this.formBuilder.group({ | ||||||||||||||||||||||||||||||||||||||||
| email: ['', [Validators.required, Validators.pattern(new RegExp(EMAIL_REGEX_STRING))]], | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -43,10 +45,10 @@ export class Login { | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const userData = this.loginForm.value as LoginUserDto; | ||||||||||||||||||||||||||||||||||||||||
| this.authService.login(userData).subscribe({ | ||||||||||||||||||||||||||||||||||||||||
| next: (response) => { | ||||||||||||||||||||||||||||||||||||||||
| next: () => { | ||||||||||||||||||||||||||||||||||||||||
| this.errorMessage = null; | ||||||||||||||||||||||||||||||||||||||||
| localStorage.setItem('access_token', response.access_token); | ||||||||||||||||||||||||||||||||||||||||
| console.log('Login successful!', response); | ||||||||||||||||||||||||||||||||||||||||
| this.loginForm.reset(); | ||||||||||||||||||||||||||||||||||||||||
| this.router.navigate(['/dashboard']); | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+48
to
52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle the
🛡️ Proposed fix- this.loginForm.reset();
- this.router.navigate(['/dashboard']);
+ this.router.navigate(['/dashboard']).then((navigated) => {
+ if (navigated) {
+ this.loginForm.reset();
+ } else {
+ this.errorMessage = 'Navigation failed. Please try again.';
+ }
+ }).catch(() => {
+ this.errorMessage = 'Navigation failed. Please try again.';
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| error: (err) => { | ||||||||||||||||||||||||||||||||||||||||
| if (err.error && err.error.message) { | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { TestBed } from '@angular/core/testing'; | ||
| import { CanActivateFn } from '@angular/router'; | ||
|
|
||
| import { authGuard } from './auth-guard'; | ||
|
|
||
| describe('authGuard', () => { | ||
| const executeGuard: CanActivateFn = (...guardParameters) => | ||
| TestBed.runInInjectionContext(() => authGuard(...guardParameters)); | ||
|
|
||
| beforeEach(() => { | ||
| TestBed.configureTestingModule({}); | ||
| }); | ||
|
|
||
| it('should be created', () => { | ||
| expect(executeGuard).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { CanActivateFn, Router } from '@angular/router'; | ||
| import { Auth } from '@/app/auth/services/auth'; | ||
| import { inject } from '@angular/core'; | ||
|
|
||
| export const authGuard: CanActivateFn = () => { | ||
| const authService = inject(Auth); | ||
| const router = inject(Router); | ||
|
|
||
| if (authService.isAuthenticated()) { | ||
| return true; | ||
| } | ||
|
|
||
| router.navigate(['/auth/login']); | ||
| return false; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { TestBed } from '@angular/core/testing'; | ||
| import { HttpInterceptorFn } from '@angular/common/http'; | ||
|
|
||
| import { tokenInterceptor } from './token-interceptor'; | ||
|
|
||
| describe('tokenInterceptor', () => { | ||
| const interceptor: HttpInterceptorFn = (req, next) => | ||
| TestBed.runInInjectionContext(() => tokenInterceptor(req, next)); | ||
|
|
||
| beforeEach(() => { | ||
| TestBed.configureTestingModule({}); | ||
| }); | ||
|
|
||
| it('should be created', () => { | ||
| expect(interceptor).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,18 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { HttpInterceptorFn } from '@angular/common/http'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Auth } from '@/app/auth/services/auth'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { inject } from '@angular/core'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export const tokenInterceptor: HttpInterceptorFn = (req, next) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const authService = inject(Auth); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const token = authService.getToken(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const clonedRequest = req.clone({ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setHeaders: { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Authorization: `Bearer ${token}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return next(clonedRequest); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Token is attached to ALL outgoing HTTP requests — restrict to API origin to prevent token leakage. If the app ever issues requests to third-party endpoints (analytics, CDN, OAuth redirects, etc.), the 🔒 Proposed fix+import { environment } from '@/environments/environment';
export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth);
const token = authService.getToken();
- if (token) {
+ if (token && req.url.startsWith(environment.apiUrl)) {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(clonedRequest);
}
return next(req);
};Adjust the condition to match your actual API base URL constant. Even a simple 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| return next(req); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,20 +5,43 @@ import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto'; | |||||||||||||||||||||||||||
| import { UserResponseDto } from '@shared/dtos/user/user-response.dto'; | ||||||||||||||||||||||||||||
| import { LoginUserDto } from '@shared/dtos/auth/login-user.dto'; | ||||||||||||||||||||||||||||
| import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto'; | ||||||||||||||||||||||||||||
| import { Observable } from 'rxjs'; | ||||||||||||||||||||||||||||
| import { Observable, tap } from 'rxjs'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @Injectable({ | ||||||||||||||||||||||||||||
| providedIn: 'root', | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
| export class Auth { | ||||||||||||||||||||||||||||
| private httpClient = inject(HttpClient); | ||||||||||||||||||||||||||||
| private apiUrl = `${environment.apiUrl}/auth`; | ||||||||||||||||||||||||||||
| private readonly TOKEN_KEY = 'access_token'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| register(userData: RegisterUserDto): Observable<UserResponseDto> { | ||||||||||||||||||||||||||||
| return this.httpClient.post<UserResponseDto>(`${this.apiUrl}/register`, userData); | ||||||||||||||||||||||||||||
| return this.httpClient | ||||||||||||||||||||||||||||
| .post<UserResponseDto>(`${this.apiUrl}/register`, userData) | ||||||||||||||||||||||||||||
| .pipe(tap((response: UserResponseDto) => this.setSession(response))); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| login(userData: LoginUserDto): Observable<AccessTokenDto> { | ||||||||||||||||||||||||||||
| return this.httpClient.post<AccessTokenDto>(`${this.apiUrl}/login`, userData); | ||||||||||||||||||||||||||||
| return this.httpClient | ||||||||||||||||||||||||||||
| .post<AccessTokenDto>(`${this.apiUrl}/login`, userData) | ||||||||||||||||||||||||||||
| .pipe(tap((response: AccessTokenDto) => this.setSession(response))); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| logout() { | ||||||||||||||||||||||||||||
| localStorage.removeItem(this.TOKEN_KEY); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| isAuthenticated(): boolean { | ||||||||||||||||||||||||||||
| return !!localStorage.getItem(this.TOKEN_KEY); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This only checks for the presence of a token. An expired JWT stored in localStorage will return Consider decoding the token and checking its 🛡️ Proposed fix- isAuthenticated(): boolean {
- return !!localStorage.getItem(this.TOKEN_KEY);
- }
+ isAuthenticated(): boolean {
+ const token = localStorage.getItem(this.TOKEN_KEY);
+ if (!token) return false;
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ return payload.exp * 1000 > Date.now();
+ } catch {
+ return false;
+ }
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| getToken(): string | null { | ||||||||||||||||||||||||||||
| return localStorage.getItem(this.TOKEN_KEY); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private setSession(authResult: UserResponseDto | AccessTokenDto) { | ||||||||||||||||||||||||||||
| if (authResult.access_token) { | ||||||||||||||||||||||||||||
| localStorage.setItem(this.TOKEN_KEY, authResult.access_token); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
Zafar7645 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <div style="padding: 2rem"> | ||
| <h1>Welcome to Dashboard</h1> | ||
| <p>If you see your profile data below, the Interceptor works! 🎉</p> | ||
| <pre>{{ profileData | json }}</pre> | ||
Zafar7645 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
|
|
||
| import { Dashboard } from './dashboard'; | ||
| import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; | ||
|
|
||
| describe('Dashboard', () => { | ||
| let component: Dashboard; | ||
| let fixture: ComponentFixture<Dashboard>; | ||
| let httpMock: HttpTestingController; | ||
|
|
||
| beforeEach(async () => { | ||
| await TestBed.configureTestingModule({ | ||
| imports: [Dashboard], | ||
| providers: [provideHttpClientTesting()], | ||
| }).compileComponents(); | ||
|
|
||
| fixture = TestBed.createComponent(Dashboard); | ||
| component = fixture.componentInstance; | ||
| httpMock = TestBed.inject(HttpTestingController); | ||
| fixture.detectChanges(); | ||
| }); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| it('should create', () => { | ||
| httpMock.expectOne('/auth/profile').flush({ id: 1, name: 'Test', email: 'test@test.com' }); | ||
| expect(component).toBeTruthy(); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.