feat: implement user authentication with login endpoint and cookie management

This commit is contained in:
2026-04-13 17:25:34 +08:00
parent 75abbb0a2a
commit 8fbb73740c
15 changed files with 275 additions and 5 deletions
@@ -4,6 +4,7 @@ import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.algorithms.Algorithm;
import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import com.onixbyte.deltaforceguide.properties.TokenProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -26,7 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class}) @EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
@@ -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<UserResponse> 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));
}
}
@@ -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<ErrorResponse> handleBizException(BizException exception) {
var status = exception.getStatus();
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getMessage()));
}
}
@@ -0,0 +1,7 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record ErrorResponse(
String message
) {
}
@@ -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
) {
}
@@ -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()
);
}
}
@@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserManager;
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
import com.onixbyte.deltaforceguide.service.CookieService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
@@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -22,6 +24,8 @@ import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.util.WebUtils; import org.springframework.web.util.WebUtils;
import java.io.IOException; import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -29,18 +33,22 @@ import java.util.Optional;
public class TokenAuthenticationFilter extends OncePerRequestFilter { public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class); 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 UserManager userManager;
private final TokenClient tokenClient; private final TokenClient tokenClient;
private final CookieService cookieService;
private final HandlerExceptionResolver handlerExceptionResolver; private final HandlerExceptionResolver handlerExceptionResolver;
public TokenAuthenticationFilter( public TokenAuthenticationFilter(
UserManager userManager, UserManager userManager,
TokenClient tokenClient, TokenClient tokenClient,
CookieService cookieService,
HandlerExceptionResolver handlerExceptionResolver HandlerExceptionResolver handlerExceptionResolver
) { ) {
this.userManager = userManager; this.userManager = userManager;
this.tokenClient = tokenClient; this.tokenClient = tokenClient;
this.cookieService = cookieService;
this.handlerExceptionResolver = handlerExceptionResolver; this.handlerExceptionResolver = handlerExceptionResolver;
} }
@@ -70,6 +78,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
var user = userWrapper.get(); var user = userWrapper.get();
var authentication = UsernamePasswordAuthentication.authenticated(user); var authentication = UsernamePasswordAuthentication.authenticated(user);
SecurityContextHolder.getContext().setAuthentication(authentication); 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); filterChain.doFilter(request, response);
} catch (JWTVerificationException e) { } catch (JWTVerificationException e) {
log.error("JWT verification failed.", e); log.error("JWT verification failed.", e);
@@ -79,5 +94,9 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
handlerExceptionResolver.resolveException(request, response, null, e); handlerExceptionResolver.resolveException(request, response, null, e);
} }
} }
private boolean shouldRenew(Instant expiresAt) {
return Duration.between(Instant.now(), expiresAt).compareTo(ACCESS_TOKEN_RENEW_THRESHOLD) < 0;
}
} }
@@ -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();
}
}
@@ -46,5 +46,9 @@ public class UserManager {
public void deleteById(Long id) { public void deleteById(Long id) {
userRepository.deleteById(id); userRepository.deleteById(id);
} }
public Optional<User> findByUsernameOrEmail(String principal) {
return userRepository.findByUsernameOrEmail(principal);
}
} }
@@ -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
) {
}
@@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.domain.entity.User;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
@@ -25,5 +27,14 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username); boolean existsByUsername(String username);
boolean existsByEmail(String email); boolean existsByEmail(String email);
@EntityGraph(attributePaths = {"credentials"})
@Query("""
select u
from User u
where u.username = :principal
or u.email = :principal
""")
Optional<User> findByUsernameOrEmail(@Param("principal") String principal);
} }
@@ -43,7 +43,7 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
} }
// get userContainer from database // get userContainer from database
var userContainer = userManager.findByUsername(usernamePasswordAuthentication.getPrincipal()); var userContainer = userManager.findByUsernameOrEmail(usernamePasswordAuthentication.getPrincipal());
if (userContainer.isEmpty()) { if (userContainer.isEmpty()) {
log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal()); log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal());
throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。");
@@ -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();
}
}
@@ -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();
}
}
+5 -3
View File
@@ -1,12 +1,14 @@
DROP TABLE IF EXISTS app_user; DROP TABLE IF EXISTS app_user CASCADE;
CREATE TABLE app_user CREATE TABLE app_user
( (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
username VARCHAR(255) NOT NULL, 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 CREATE TABLE app_user_credential
( (
user_id BIGINT NOT NULL REFERENCES app_user (id), user_id BIGINT NOT NULL REFERENCES app_user (id),