Compare commits

...

5 Commits

Author SHA1 Message Date
Roland Schneider
f1f2fefdab logout works 2025-11-14 17:20:13 +01:00
Roland Schneider
d11b0c65e0 add refresh token 2025-11-14 17:20:13 +01:00
Roland Schneider
96af8e564b add refresh token 2025-11-14 17:20:11 +01:00
Roland Schneider
42158d1fd4 add refresh token 2025-11-14 17:19:52 +01:00
Roland Schneider
f4c0bb0b76 add refresh token 2025-11-14 17:19:07 +01:00
15 changed files with 358 additions and 45 deletions

View File

@ -3,6 +3,7 @@ import { Router, RouterOutlet } from '@angular/router';
import { MainMenu } from './components/main-menu/main-menu'; import { MainMenu } from './components/main-menu/main-menu';
import { AuthService } from './auth/auth.service'; import { AuthService } from './auth/auth.service';
import { AdminLayout } from './layout/admin-layout/admin-layout'; import { AdminLayout } from './layout/admin-layout/admin-layout';
import { finalize } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -16,7 +17,17 @@ export class App {
constructor(private authService: AuthService, private router: Router) {} constructor(private authService: AuthService, private router: Router) {}
logout(): void { logout(): void {
this.authService.logout(); // With the interceptor fixed, this is now the correct and robust way.
this.router.navigate(['/login']); // The error from a failed server logout will propagate here.
this.authService.serverSideLogout().subscribe({
next: () => {
console.log('Server-side logout successful.');
this.authService.clientSideLogout();
},
error: (err) => {
console.error('Server-side logout failed, logging out client-side anyway.', err);
this.authService.clientSideLogout();
},
});
} }
} }

View File

@ -1,36 +1,76 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { Router } from '@angular/router';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class AuthService { export class AuthService {
private readonly TOKEN_KEY = 'access_token'; private readonly ACCESS_TOKEN_KEY = 'accessToken';
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different
constructor(private http: HttpClient) {} constructor(private http: HttpClient, private router: Router) {}
login(credentials: { username: string; password: string }): Observable<any> { login(credentials: { username: string; password: string }): Observable<any> {
return this.http.post<{ access_token: string }>(`${this.apiUrl}/login`, credentials).pipe( return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe(
tap((response) => this.setToken(response.access_token)) tap((response) => this.setTokens(response.accessToken, response.refreshToken))
); );
} }
logout(): void { /**
localStorage.removeItem(this.TOKEN_KEY); * Makes a best-effort call to the server to invalidate the refresh token.
*/
serverSideLogout(): Observable<any> {
return this.http.post(`${this.apiUrl}/logout`, {});
} }
getToken(): string | null { /**
return localStorage.getItem(this.TOKEN_KEY); * Performs the client-side cleanup, removing tokens and redirecting to login.
* This is the definitive logout action from the user's perspective.
*/
clientSideLogout(): void {
console.info("clientSideLogout")
this.removeTokens();
this.router.navigate(['/login']);
}
refreshToken(): Observable<any> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
// If no refresh token is present, logout and return an error.
this.clientSideLogout();
return throwError(() => new Error('No refresh token available'));
}
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
}).pipe(
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
);
}
getAccessToken(): string | null {
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
}
getRefreshToken(): string | null {
return localStorage.getItem(this.REFRESH_TOKEN_KEY);
} }
isLoggedIn(): boolean { isLoggedIn(): boolean {
return this.getToken() !== null; return this.getAccessToken() !== null;
} }
private setToken(token: string): void { private setTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem(this.TOKEN_KEY, token); localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
}
private removeTokens(): void {
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
} }
} }

View File

@ -4,26 +4,81 @@ import {
HttpHandler, HttpHandler,
HttpInterceptor, HttpInterceptor,
HttpRequest, HttpRequest,
HttpErrorResponse,
} from '@angular/common/http'; } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, switchMap, filter, take, finalize } from 'rxjs/operators'; // Import finalize
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class JwtInterceptor implements HttpInterceptor { export class JwtInterceptor implements HttpInterceptor {
private isRefreshing = false;
// Initialize refreshTokenSubject with null
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
intercept( intercept(
request: HttpRequest<any>, request: HttpRequest<any>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<any>> {
const token = this.authService.getToken(); if (request.url.includes('/auth/refresh')) {
if (token) { return next.handle(request);
request = request.clone({ }
const accessToken = this.authService.getAccessToken();
if (accessToken) {
request = this.addToken(request, accessToken);
}
return next.handle(request).pipe(
catchError((error) => {
if (error instanceof HttpErrorResponse && error.status === 401) {
return this.handle401Error(request, next);
}
return throwError(() => error);
})
);
}
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
if (!this.isRefreshing) {
this.isRefreshing = true;
// Reset the refreshTokenSubject to null so that subsequent requests will wait
// this.refreshTokenSubject.next(null);
this.refreshTokenSubject = new BehaviorSubject<any>(null);
return this.authService.refreshToken().pipe(
switchMap((token: any) => {
this.refreshTokenSubject.next(token.accessToken);
return next.handle(this.addToken(request, token.accessToken));
}),
catchError((err) => {
// If refresh fails, logout the user
this.authService.clientSideLogout();
return throwError(() => err);
}),
finalize(() => {
// When the refresh attempt completes, set isRefreshing to false
this.isRefreshing = false;
})
);
} else {
// If a refresh is already in progress, wait for it to complete
return this.refreshTokenSubject.pipe(
filter(token => token != null),
take(1),
switchMap(jwt => next.handle(this.addToken(request, jwt)))
);
}
}
private addToken(request: HttpRequest<any>, token: string) {
return request.clone({
setHeaders: { setHeaders: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
} }
return next.handle(request);
}
} }

View File

@ -1,4 +1,4 @@
POST http://localhost:3000/auth/login POST {{apiBaseUrl}}/auth/login
Content-Type: application/json Content-Type: application/json
{ {
@ -6,10 +6,17 @@ Content-Type: application/json
"password": "123456" "password": "123456"
} }
> {% client.global.set("auth_token", response.body.access_token); %} > {% client.global.set("auth_token", response.body.accessToken); %}
### GET request with parameter ### GET request with parameter
GET http://localhost:3000/users GET {{apiBaseUrl}}/users
Accept: application/json
Authorization: Bearer {{auth_token}}
### GET request with parameter
POST {{apiBaseUrl}}/auth/logout
Accept: application/json Accept: application/json
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}

View File

@ -0,0 +1,5 @@
{
"dev": {
"apiBaseUrl": "http://localhost:3000/api"
}
}

View File

@ -1,6 +1,16 @@
import { Controller, Post, Body, ValidationPipe } from '@nestjs/common'; import {
import { AuthService } from './auth.service'; Controller,
Post,
Body,
ValidationPipe,
UseGuards,
Req,
} from '@nestjs/common';
import { LoginRequestDto } from './dto/login-request.dto'; import { LoginRequestDto } from './dto/login-request.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtRefreshAuthGuard } from './jwt-refresh-auth.guard';
import express from 'express';
import { AuthService } from './auth.service';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -10,4 +20,18 @@ export class AuthController {
async login(@Body(new ValidationPipe()) body: LoginRequestDto) { async login(@Body(new ValidationPipe()) body: LoginRequestDto) {
return await this.authService.login(body); return await this.authService.login(body);
} }
@UseGuards(JwtAuthGuard)
@Post('logout')
async logout(@Req() req: express.Request) {
const user = req.user as { sub: number };
return await this.authService.logout(user.sub);
}
@UseGuards(JwtRefreshAuthGuard)
@Post('refresh')
async refresh(@Req() req: express.Request) {
const user = req.user as { sub: number; refreshToken: string };
return await this.authService.refreshToken(user.sub, user.refreshToken);
}
} }

View File

@ -6,22 +6,27 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtRefreshTokenStrategy } from './jwt-refresh.strategy';
import { StringValue } from 'ms';
@Module({ @Module({
imports: [ imports: [
ConfigModule, // <--- Import ConfigModule here ConfigModule,
UserModule, UserModule,
PassportModule, PassportModule,
// Restore the correct async registration for JwtModule
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '60m' }, signOptions: {
expiresIn: configService.get<StringValue>('JWT_EXPIRATION_TIME'),
},
}), }),
}), }),
], ],
providers: [AuthService, JwtStrategy], providers: [AuthService, JwtStrategy, JwtRefreshTokenStrategy],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -4,12 +4,15 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { User } from '../entity/user'; import { User } from '../entity/user';
import { LoginRequest, LoginResponse } from '../types'; import { LoginRequest, LoginResponse } from '../types';
import { ConfigService } from '@nestjs/config';
import type { StringValue } from 'ms';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private userService: UserService, private userService: UserService,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<User | null> { async validateUser(username: string, pass: string): Promise<User | null> {
@ -34,19 +37,84 @@ export class AuthService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const tokens = await this.getTokens(user);
await this.userService.setRefreshToken(user.id, tokens.refreshToken);
return tokens;
}
async logout(userId: number): Promise<void> {
await this.userService.setRefreshToken(userId, null);
}
async refreshToken(
userId: number,
refreshToken: string,
): Promise<LoginResponse> {
const user = await this.userService.findOne(userId);
if (!user || !user.hashedRefreshToken) {
throw new UnauthorizedException('Access Denied');
}
const refreshTokenMatches = await bcrypt.compare(
refreshToken,
user.hashedRefreshToken,
);
if (!refreshTokenMatches) {
throw new UnauthorizedException('Access Denied');
}
const tokens = await this.getTokens(user);
await this.userService.setRefreshToken(user.id, tokens.refreshToken);
return tokens;
}
private async getTokens(user: User): Promise<LoginResponse> {
const roles: Set<string> = new Set<string>(); const roles: Set<string> = new Set<string>();
for (const group of user.groups ?? []) { for (const group of user.groups ?? []) {
for (const role of group.roles ?? []) { for (const role of group.roles ?? []) {
roles.add(role.name); roles.add(role.name);
} }
} }
const payload = {
const jwtSecret = this.configService.get<string>('JWT_SECRET');
const jwtexpirationtime = this.configService.get<string>(
'JWT_EXPIRATION_TIME',
);
console.info(
'creating. jwt secret is: ' + jwtSecret + ',' + jwtexpirationtime,
);
// let accessToken: string, refreshToken: string;
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
username: user.username, username: user.username,
sub: user.id, sub: user.id,
roles: Array.from(roles), roles: Array.from(roles),
}; },
{
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: +this.configService.get<StringValue>('JWT_EXPIRATION_TIME')!,
},
),
this.jwtService.signAsync(
{
sub: user.id,
},
{
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<StringValue>(
'JWT_REFRESH_EXPIRATION_TIME',
),
},
),
]);
return { return {
access_token: this.jwtService.sign(payload), accessToken,
refreshToken,
}; };
} }
} }

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@ -0,0 +1,47 @@
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy, StrategyOptionsWithRequest } from 'passport-jwt';
import { Request } from 'express';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Define the shape of the JWT payload
interface JwtPayload {
sub: number;
// iat and exp are automatically added by passport-jwt
iat: number;
exp: number;
}
// Define the shape of the object returned by the validate function
interface JwtPayloadWithRefreshToken extends JwtPayload {
refreshToken: string;
}
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
passReqToCallback: true,
} as StrategyOptionsWithRequest);
}
validate(req: Request, payload: JwtPayload): JwtPayloadWithRefreshToken {
const refreshToken = req.get('Authorization')?.replace('Bearer', '').trim();
// Ensure refreshToken is not undefined before returning
if (!refreshToken) {
// This case should be rare given the guard is used, but it's good practice
// to handle it. Depending on strictness, you might throw an error.
// For now, we'll proceed, but in a real-world scenario, logging or an error
// might be better.
return { ...payload, refreshToken: '' };
}
return { ...payload, refreshToken };
}
}

View File

@ -3,18 +3,32 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Role } from './role.enum'; import { Role } from './role.enum';
import { Request } from 'express';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) { constructor(private configService: ConfigService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') as string, // DO NOT use secretOrKey here. It causes a race condition with ConfigService.
// Instead, use secretOrKeyProvider to look up the secret dynamically
// at request time, ensuring ConfigService is ready.
secretOrKeyProvider: (
request: Request,
rawJwtToken: any,
done: (err: any, secretOrKey?: string | Buffer) => void,
) => {
const secretKey = this.configService.get<string>('JWT_SECRET');
console.info('secretKey', secretKey);
done(null, secretKey);
},
}); });
} }
validate(payload: { sub: string; username: string; roles: Role[] }) { // The payload is already validated by passport-jwt at this point,
// so we can trust its contents.
validate(payload: { sub: number; username: string; roles: Role[] }) {
return { return {
userId: payload.sub, userId: payload.sub,
username: payload.username, username: payload.username,

View File

@ -24,4 +24,7 @@ export class User {
@ManyToMany(() => UserGroup) @ManyToMany(() => UserGroup)
@JoinTable() @JoinTable()
groups: UserGroup[]; groups: UserGroup[];
@Column({ type: 'varchar', nullable: true })
hashedRefreshToken: string | null;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddRefreshTokenToUserObject1763106308120 implements MigrationInterface {
name = 'AddRefreshTokenToUserObject1763106308120'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" ADD "hashedRefreshToken" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hashedRefreshToken"`);
}
}

View File

@ -5,7 +5,6 @@ export interface LoginRequest{
} }
export interface LoginResponse { export interface LoginResponse {
access_token: string; accessToken: string;
refreshToken: string;
} }

View File

@ -54,4 +54,20 @@ export class UserService {
this.logger.log(`Removing user with id: ${id}`, 'UserService'); this.logger.log(`Removing user with id: ${id}`, 'UserService');
await this.usersRepository.delete(id); await this.usersRepository.delete(id);
} }
async setRefreshToken(
id: number,
refreshToken: string | null,
): Promise<void> {
this.logger.log(
`Updating refresh token for user with id: ${id}`,
'UserService',
);
if (refreshToken) {
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
await this.usersRepository.update(id, { hashedRefreshToken });
} else {
await this.usersRepository.update(id, { hashedRefreshToken: null });
}
}
} }