From c4c67e1ef12f7a60befec096ec6a38ed18dd73fa Mon Sep 17 00:00:00 2001 From: liosha84 <138026690+liosha84@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:31:46 +0300 Subject: [PATCH] Implement refresh token functionality and enhance error handling in authentication --- HELP.md | 3 +- .../src/app/helpers/auth.interceptor.ts | 26 ++++++++- jambotron-ui/src/app/services/auth.service.ts | 3 + .../jambotron/controllers/AuthController.java | 56 +++++++++++++++++-- .../exceptionHandlers/ErrorMessage.java | 33 +++++++++++ .../TokenControllerAdvice.java | 25 +++++++++ .../exceptions/TokenRefreshException.java | 14 +++++ .../jambotron/model/RefreshToken.java | 54 ++++++++++++++++++ .../repository/RefreshTokenRepository.java | 18 ++++++ .../jambotron/security/jwt/JwtUtils.java | 36 ++++++++++++ .../services/RefreshTokenService.java | 55 ++++++++++++++++++ src/main/resources/application-dev.properties | 15 +++-- 12 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/ErrorMessage.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/TokenControllerAdvice.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/exceptions/TokenRefreshException.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/model/RefreshToken.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/security/services/RefreshTokenService.java diff --git a/HELP.md b/HELP.md index 47eb320..82aee94 100644 --- a/HELP.md +++ b/HELP.md @@ -49,8 +49,9 @@ To run the application, follow these steps: GRANT ALL PRIVILEGES ON DATABASE jambotronDB TO admin; ``` - If you are using Docker, you can run PostgreSQL using the following command: + - port 5433 is used. ```bash - docker run --name postgresDB -p 5432:5432 -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=postgrespw -e POSTGRES_DB=jambotronDB -d postgres + docker run --name postgresDB -p 5433:5432 -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=postgrespw -e POSTGRES_DB=jambotronDB -d postgres ``` - if you are wont to use pgadmin ```bash diff --git a/jambotron-ui/src/app/helpers/auth.interceptor.ts b/jambotron-ui/src/app/helpers/auth.interceptor.ts index 7769749..be4ed85 100644 --- a/jambotron-ui/src/app/helpers/auth.interceptor.ts +++ b/jambotron-ui/src/app/helpers/auth.interceptor.ts @@ -3,10 +3,11 @@ import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http'; import { TokenStorageService } from '../services/token-storage.service'; -import {catchError, Observable, throwError} from 'rxjs'; +import {catchError, Observable, switchMap, throwError} from 'rxjs'; import {EventBusService} from '../_shared/event-bus.service'; import {EventData} from '../_shared/event.class'; import {environment} from '../../environments/environment'; +import {AuthService} from '../services/auth.service'; const TOKEN_HEADER_KEY = 'Authorization'; // for Spring Boot back-end @@ -15,7 +16,11 @@ export class AuthInterceptor implements HttpInterceptor { private isRefreshing = false; enviorment = environment; - constructor(private tokenStorageService: TokenStorageService, private eventBusService: EventBusService) { } + constructor( + private tokenStorageService: TokenStorageService, + private eventBusService: EventBusService, + private authService:AuthService + ) { } intercept(req: HttpRequest, next: HttpHandler): Observable> { @@ -43,7 +48,22 @@ export class AuthInterceptor implements HttpInterceptor { this.isRefreshing = true; if (this.tokenStorageService.isLoggedIn()) { - this.eventBusService.emit(new EventData('logout', null)); + return this.authService.refreshToken().pipe( + switchMap(() => { + this.isRefreshing = false; + console.log("refresh token"); + return next.handle(request); + }), + catchError((error) => { + this.isRefreshing = false; + + if (error.status == '403') { + this.eventBusService.emit(new EventData('logout', null)); + } + + return throwError(() => error); + }) + ); } } diff --git a/jambotron-ui/src/app/services/auth.service.ts b/jambotron-ui/src/app/services/auth.service.ts index f9d5c08..a3438d1 100644 --- a/jambotron-ui/src/app/services/auth.service.ts +++ b/jambotron-ui/src/app/services/auth.service.ts @@ -36,4 +36,7 @@ export class AuthService { logout(): Observable { return this.http.post(AUTH_API + 'signout', { }, httpOptions); } + refreshToken() { + return this.http.post(AUTH_API + 'refreshtoken', { }, httpOptions); + } } diff --git a/src/main/java/com/jambotronGroup/jambotron/controllers/AuthController.java b/src/main/java/com/jambotronGroup/jambotron/controllers/AuthController.java index a877b3c..d5dd836 100644 --- a/src/main/java/com/jambotronGroup/jambotron/controllers/AuthController.java +++ b/src/main/java/com/jambotronGroup/jambotron/controllers/AuthController.java @@ -1,7 +1,9 @@ package com.jambotronGroup.jambotron.controllers; +import com.jambotronGroup.jambotron.exceptions.TokenRefreshException; import com.jambotronGroup.jambotron.model.ERole; +import com.jambotronGroup.jambotron.model.RefreshToken; import com.jambotronGroup.jambotron.model.Role; import com.jambotronGroup.jambotron.model.User; import com.jambotronGroup.jambotron.payload.request.LoginRequest; @@ -11,7 +13,9 @@ import com.jambotronGroup.jambotron.payload.response.UserInfoResponse; import com.jambotronGroup.jambotron.repository.RoleRepository; import com.jambotronGroup.jambotron.repository.UserRepository; import com.jambotronGroup.jambotron.security.jwt.JwtUtils; +import com.jambotronGroup.jambotron.security.services.RefreshTokenService; import com.jambotronGroup.jambotron.security.services.UserDetailsImpl; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +41,7 @@ import java.util.stream.Collectors; @RequestMapping("/api/auth") public class AuthController { - private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + private static final Logger _logger = LoggerFactory.getLogger(AuthController.class); @Autowired AuthenticationManager authenticationManager; @@ -50,6 +54,9 @@ public class AuthController { @Autowired PasswordEncoder encoder; + @Autowired + RefreshTokenService refreshTokenService; + @Autowired JwtUtils jwtUtils; @@ -69,9 +76,16 @@ public class AuthController { .map(item -> item.getAuthority()) .collect(Collectors.toList()); - logger.info("User {} authenticated successfully with roles: {}", userDetails.getUsername(), roles); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId()); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + ResponseCookie jwtRefreshCookie = jwtUtils.generateRefreshJwtCookie(refreshToken.getToken()); + + + _logger.info("User {} authenticated successfully with roles: {}", userDetails.getUsername(), roles); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .header(HttpHeaders.SET_COOKIE, jwtRefreshCookie.toString()) .body(new UserInfoResponse( userDetails.getId(), userDetails.getUsername(), @@ -134,8 +148,42 @@ public class AuthController { @PostMapping("/signout") public ResponseEntity logoutUser() { + Object principle = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principle.toString() != "anonymousUser") { + Long userId = ((UserDetailsImpl) principle).getId(); + refreshTokenService.deleteByUserId(userId); + } + ResponseCookie cookie = jwtUtils.getCleanJwtCookie(); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()) + ResponseCookie jwtRefreshCookie = jwtUtils.getCleanJwtRefreshCookie(); + + _logger.info("User signed out successfully, cookies cleared."); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.SET_COOKIE, jwtRefreshCookie.toString()) .body(new MessageResponse("You've been signed out!")); } + + @PostMapping("/refreshtoken") + public ResponseEntity refreshtoken(HttpServletRequest request) { + String refreshToken = jwtUtils.getJwtRefreshFromCookies(request); + + if ((refreshToken != null) && (refreshToken.length() > 0)) { + return refreshTokenService.findByToken(refreshToken) + .map(refreshTokenService::verifyExpiration) + .map(RefreshToken::getUser) + .map(user -> { + ResponseCookie jwtCookie = jwtUtils.generateJwtCookie(user); + + _logger.info("Refresh token for user {} is valid, generating new JWT cookie.", user.getUsername()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .body(new MessageResponse("Token is refreshed successfully!")); + }) + .orElseThrow(() -> new TokenRefreshException(refreshToken, + "Refresh token is not in database!")); + } + + return ResponseEntity.badRequest().body(new MessageResponse("Refresh Token is empty!")); + } } diff --git a/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/ErrorMessage.java b/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/ErrorMessage.java new file mode 100644 index 0000000..4010f2d --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/ErrorMessage.java @@ -0,0 +1,33 @@ +package com.jambotronGroup.jambotron.exceptionHandlers; + +import java.util.Date; + +public class ErrorMessage { + private int statusCode; + private Date timestamp; + private String message; + private String description; + + public ErrorMessage(int statusCode, Date timestamp, String message, String description) { + this.statusCode = statusCode; + this.timestamp = timestamp; + this.message = message; + this.description = description; + } + + public int getStatusCode() { + return statusCode; + } + + public Date getTimestamp() { + return timestamp; + } + + public String getMessage() { + return message; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/TokenControllerAdvice.java b/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/TokenControllerAdvice.java new file mode 100644 index 0000000..a013068 --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/exceptionHandlers/TokenControllerAdvice.java @@ -0,0 +1,25 @@ +package com.jambotronGroup.jambotron.exceptionHandlers; + +import java.util.Date; + +import com.jambotronGroup.jambotron.exceptions.TokenRefreshException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + + +@RestControllerAdvice +public class TokenControllerAdvice { + + @ExceptionHandler(value = TokenRefreshException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorMessage handleTokenRefreshException(TokenRefreshException ex, WebRequest request) { + return new ErrorMessage( + HttpStatus.FORBIDDEN.value(), + new Date(), + ex.getMessage(), + request.getDescription(false)); + } +} diff --git a/src/main/java/com/jambotronGroup/jambotron/exceptions/TokenRefreshException.java b/src/main/java/com/jambotronGroup/jambotron/exceptions/TokenRefreshException.java new file mode 100644 index 0000000..608633b --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/exceptions/TokenRefreshException.java @@ -0,0 +1,14 @@ +package com.jambotronGroup.jambotron.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class TokenRefreshException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public TokenRefreshException(String token, String message) { + super(String.format("Failed for [%s]: %s", token, message)); + } +} diff --git a/src/main/java/com/jambotronGroup/jambotron/model/RefreshToken.java b/src/main/java/com/jambotronGroup/jambotron/model/RefreshToken.java new file mode 100644 index 0000000..784ee79 --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/model/RefreshToken.java @@ -0,0 +1,54 @@ +package com.jambotronGroup.jambotron.model; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity(name = "refreshtoken") +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Instant expiryDate; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/com/jambotronGroup/jambotron/repository/RefreshTokenRepository.java b/src/main/java/com/jambotronGroup/jambotron/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..88dca1d --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/repository/RefreshTokenRepository.java @@ -0,0 +1,18 @@ +package com.jambotronGroup.jambotron.repository; + +import com.jambotronGroup.jambotron.model.RefreshToken; +import com.jambotronGroup.jambotron.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + + @Repository + public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + + @Modifying + int deleteByUser(User user); + } + diff --git a/src/main/java/com/jambotronGroup/jambotron/security/jwt/JwtUtils.java b/src/main/java/com/jambotronGroup/jambotron/security/jwt/JwtUtils.java index 86c43fd..f40b210 100644 --- a/src/main/java/com/jambotronGroup/jambotron/security/jwt/JwtUtils.java +++ b/src/main/java/com/jambotronGroup/jambotron/security/jwt/JwtUtils.java @@ -2,6 +2,7 @@ package com.jambotronGroup.jambotron.security.jwt; +import com.jambotronGroup.jambotron.model.User; import com.jambotronGroup.jambotron.security.services.UserDetailsImpl; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; @@ -31,6 +32,9 @@ public class JwtUtils { @Value("${app.jwtCookieName}") private String jwtCookie; + @Value("${app.jwtRefreshCookieName}") + private String jwtRefreshCookie; + public String getJwtFromCookies(HttpServletRequest request) { Cookie cookie = WebUtils.getCookie(request, jwtCookie); if (cookie != null) { @@ -46,6 +50,38 @@ public class JwtUtils { return cookie; } + public ResponseCookie generateRefreshJwtCookie(String refreshToken) { + return generateCookie(jwtRefreshCookie, refreshToken, "/api/auth/refreshtoken"); + } + + public String getJwtRefreshFromCookies(HttpServletRequest request) { + return getCookieValueByName(request, jwtRefreshCookie); + } + + public ResponseCookie getCleanJwtRefreshCookie() { + ResponseCookie cookie = ResponseCookie.from(jwtRefreshCookie, null).path("/api/auth/refreshtoken").build(); + return cookie; + } + + public ResponseCookie generateJwtCookie(User user) { + String jwt = generateTokenFromUsername(user.getUsername()); + return generateCookie(jwtCookie, jwt, "/api"); + } + + private ResponseCookie generateCookie(String name, String value, String path) { + ResponseCookie cookie = ResponseCookie.from(name, value).path(path).maxAge(24 * 60 * 60).httpOnly(true).build(); + return cookie; + } + + private String getCookieValueByName(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + if (cookie != null) { + return cookie.getValue(); + } else { + return null; + } + } + public ResponseCookie getCleanJwtCookie() { ResponseCookie cookie = ResponseCookie.from(jwtCookie, null).path("/api").build(); return cookie; diff --git a/src/main/java/com/jambotronGroup/jambotron/security/services/RefreshTokenService.java b/src/main/java/com/jambotronGroup/jambotron/security/services/RefreshTokenService.java new file mode 100644 index 0000000..0c91bb9 --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/security/services/RefreshTokenService.java @@ -0,0 +1,55 @@ +package com.jambotronGroup.jambotron.security.services; + +import com.jambotronGroup.jambotron.exceptions.TokenRefreshException; +import com.jambotronGroup.jambotron.model.RefreshToken; +import com.jambotronGroup.jambotron.repository.RefreshTokenRepository; +import com.jambotronGroup.jambotron.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Service +public class RefreshTokenService { + @Value("${app.jwtRefreshExpirationMs}") + private Long refreshTokenDurationMs; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private UserRepository userRepository; + + public Optional findByToken(String token) { + return refreshTokenRepository.findByToken(token); + } + + public RefreshToken createRefreshToken(Long userId) { + RefreshToken refreshToken = new RefreshToken(); + + refreshToken.setUser(userRepository.findById(userId).get()); + refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs)); + refreshToken.setToken(UUID.randomUUID().toString()); + + refreshToken = refreshTokenRepository.save(refreshToken); + return refreshToken; + } + + public RefreshToken verifyExpiration(RefreshToken token) { + if (token.getExpiryDate().compareTo(Instant.now()) < 0) { + refreshTokenRepository.delete(token); + throw new TokenRefreshException(token.getToken(), "Refresh token was expired. Please make a new signin request"); + } + + return token; + } + + @Transactional + public int deleteByUserId(Long userId) { + return refreshTokenRepository.deleteByUser(userRepository.findById(userId).get()); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index ec0b3a8..f011886 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -5,14 +5,14 @@ spring.application.name=jambotron server.port=8082 #============Localhost Configurations======================== -spring.datasource.url= jdbc:postgresql://localhost:5432/jambotronDB +spring.datasource.url= jdbc:postgresql://localhost:5433/jambotronDB spring.datasource.username= admin spring.datasource.password= postgrespw spring.flyway.baseline-on-migrate=true spring.flyway.validate-on-migrate=true -spring.flyway.url=jdbc:postgresql://localhost:5432/jambotronDB +spring.flyway.url=jdbc:postgresql://localhost:5433/jambotronDB spring.flyway.user=admin spring.flyway.password=postgrespw @@ -30,9 +30,14 @@ spring.ai.zhipuai.api-key = 628447c8c65845a48a7226391464a2ea.Dw8ci6TiW0BRF5LI #============ Custom App Properties -app.jwtSecret= ======================spring=back==================== -app.jwtExpirationMs= 800000 -app.jwtCookieName=springangularts +app.jwtSecret= ======================jambotron=back================= +app.jwtCookieName=jambotron-jwt-cookie +app.jwtRefreshCookieName= jambotron-jwt-cookie-refresh +#app.jwtExpirationMs= 800000 +#app.jwtRefreshExpirationMs= 86400000 + +app.jwtExpirationMs= 30000 +app.jwtRefreshExpirationMs= 60000 #=============File Upload Configurations======================== spring.servlet.multipart.max-file-size=50MB