diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index effe916..1ac6d71 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; +import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -26,7 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @EnableMethodSecurity -@EnableConfigurationProperties({TokenProperties.class}) +@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) public class SecurityConfig { @Bean diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java new file mode 100644 index 0000000..172f8b7 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -0,0 +1,40 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserResponse; +import com.onixbyte.deltaforceguide.client.TokenClient; +import com.onixbyte.deltaforceguide.service.AuthService; +import com.onixbyte.deltaforceguide.service.CookieService; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final TokenClient tokenClient; + private final CookieService cookieService; + + public AuthController(AuthService authService, TokenClient tokenClient, CookieService cookieService) { + this.authService = authService; + this.tokenClient = tokenClient; + this.cookieService = cookieService; + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + var user = authService.login(request); + var accessToken = tokenClient.generateToken(user); + var accessTokenCookie = cookieService.buildCookie("AccessToken", accessToken); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .body(UserResponse.from(user)); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..c0b19f1 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -0,0 +1,19 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse; +import com.onixbyte.deltaforceguide.exeption.BizException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public ResponseEntity handleBizException(BizException exception) { + var status = exception.getStatus(); + return ResponseEntity.status(status) + .body(new ErrorResponse(exception.getMessage())); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java new file mode 100644 index 0000000..f1efbfa --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record ErrorResponse( + String message +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java new file mode 100644 index 0000000..5dda62b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "登录名称不能为空") String principle, + @NotBlank(message = "登录口令不能为空") String credential +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java new file mode 100644 index 0000000..75d9188 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java @@ -0,0 +1,17 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.User; + +public record UserResponse( + Long id, + String username, + String email +) { + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getUsername(), + user.getEmail() + ); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index 8cfd219..f4b9220 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import com.onixbyte.deltaforceguide.service.CookieService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; @@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -22,6 +24,8 @@ import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.util.WebUtils; import java.io.IOException; +import java.time.Duration; +import java.time.Instant; import java.util.Objects; import java.util.Optional; @@ -29,18 +33,22 @@ import java.util.Optional; public class TokenAuthenticationFilter extends OncePerRequestFilter { private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class); + private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5); private final UserManager userManager; private final TokenClient tokenClient; + private final CookieService cookieService; private final HandlerExceptionResolver handlerExceptionResolver; public TokenAuthenticationFilter( UserManager userManager, TokenClient tokenClient, + CookieService cookieService, HandlerExceptionResolver handlerExceptionResolver ) { this.userManager = userManager; this.tokenClient = tokenClient; + this.cookieService = cookieService; this.handlerExceptionResolver = handlerExceptionResolver; } @@ -70,6 +78,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { var user = userWrapper.get(); var authentication = UsernamePasswordAuthentication.authenticated(user); SecurityContextHolder.getContext().setAuthentication(authentication); + + if (shouldRenew(decodedToken.getExpiresAt().toInstant())) { + var renewedToken = tokenClient.generateToken(user); + var renewedTokenCookie = cookieService.buildCookie("AccessToken", renewedToken); + response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString()); + } + filterChain.doFilter(request, response); } catch (JWTVerificationException e) { log.error("JWT verification failed.", e); @@ -79,5 +94,9 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { handlerExceptionResolver.resolveException(request, response, null, e); } } + + private boolean shouldRenew(Instant expiresAt) { + return Duration.between(Instant.now(), expiresAt).compareTo(ACCESS_TOKEN_RENEW_THRESHOLD) < 0; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java new file mode 100644 index 0000000..7bec528 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java @@ -0,0 +1,37 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.CookieProperties; +import org.springframework.boot.web.server.Cookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CookieManager { + + private final CookieProperties cookieProperties; + + public CookieManager(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } + + public Boolean getHttpOnly() { + return cookieProperties.httpOnly(); + } + + public Boolean getSecure() { + return cookieProperties.secure(); + } + + public Cookie.SameSite getSameSite() { + return cookieProperties.sameSite(); + } + + public String getPath() { + return cookieProperties.path(); + } + + public Duration getMaxAge() { + return cookieProperties.maxAge(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java index 08ffa3c..fb8ac56 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -46,5 +46,9 @@ public class UserManager { public void deleteById(Long id) { userRepository.deleteById(id); } + + public Optional findByUsernameOrEmail(String principal) { + return userRepository.findByUsernameOrEmail(principal); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java new file mode 100644 index 0000000..86dab77 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java @@ -0,0 +1,17 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.boot.web.server.Cookie; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.cookie") +public record CookieProperties( + @DefaultValue("true") Boolean httpOnly, + @DefaultValue("true") Boolean secure, + @DefaultValue("/") String path, + @DefaultValue("PT2H") Duration maxAge, + @DefaultValue("LAX") Cookie.SameSite sameSite +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java index 3efa050..7cecaad 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.domain.entity.User; import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -25,5 +27,14 @@ public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); + + @EntityGraph(attributePaths = {"credentials"}) + @Query(""" + select u + from User u + where u.username = :principal + or u.email = :principal + """) + Optional findByUsernameOrEmail(@Param("principal") String principal); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index b32c814..d5e9a65 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -43,7 +43,7 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro } // get userContainer from database - var userContainer = userManager.findByUsername(usernamePasswordAuthentication.getPrincipal()); + var userContainer = userManager.findByUsernameOrEmail(usernamePasswordAuthentication.getPrincipal()); if (userContainer.isEmpty()) { log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal()); throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java new file mode 100644 index 0000000..b1bbfc7 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -0,0 +1,40 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.stereotype.Service; + +import java.util.Objects; +import java.util.Optional; + +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + private final AuthenticationManager authenticationManager; + + public AuthService(AuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + public User login(LoginRequest request) { + var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication + .unauthenticated(request.principle(), request.credential())); + if (!(_authentication instanceof UsernamePasswordAuthentication authentication)) { + log.error( + "Type mismatched, required type is UsernamePasswordAuthentication but got {}.", + _authentication.getClass() + ); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "登录服务异常,请稍后再试。"); + } + + return authentication.getDetails(); + } + +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java new file mode 100644 index 0000000..764b47b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java @@ -0,0 +1,47 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.manager.CookieManager; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class CookieService { + + private final CookieManager cookieManager; + + public CookieService(CookieManager cookieManager) { + this.cookieManager = cookieManager; + } + + public ResponseCookie buildCookie(String cookieName, String value) { + return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); + } + + public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { + return buildCookieInternal(cookieName, value, validDuration); + } + + /** + * Creates a response cookie builder with specified name, value and valid duration. + * + * @param name name of the cookie + * @param value value of the cookie + * @param maxAge valid duration of the cookie + * @return cookie builder + */ + protected ResponseCookie buildCookieInternal( + String name, + String value, + Duration maxAge + ) { + return ResponseCookie.from(name, value) + .secure(cookieManager.getSecure()) + .maxAge(maxAge) + .httpOnly(cookieManager.getHttpOnly()) + .path(cookieManager.getPath()) + .sameSite(cookieManager.getSameSite().attributeValue()) + .build(); + } +} diff --git a/src/main/resources/db/migration/V4__user.sql b/src/main/resources/db/migration/V4__user.sql index f3df299..0215d26 100644 --- a/src/main/resources/db/migration/V4__user.sql +++ b/src/main/resources/db/migration/V4__user.sql @@ -1,12 +1,14 @@ -DROP TABLE IF EXISTS app_user; +DROP TABLE IF EXISTS app_user CASCADE; CREATE TABLE app_user ( id BIGSERIAL NOT NULL PRIMARY KEY, username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL + email VARCHAR(255) NOT NULL, + CONSTRAINT app_user_username_key UNIQUE (username), + CONSTRAINT app_user_email_key UNIQUE (email) ); -DROP TABLE IF EXISTS app_user_credential; +DROP TABLE IF EXISTS app_user_credential CASCADE; CREATE TABLE app_user_credential ( user_id BIGINT NOT NULL REFERENCES app_user (id),