feat: implement user authentication with login endpoint and cookie management
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user