[admin] 로그인 로그아웃 작업 추가

This commit is contained in:
2026-04-30 08:32:03 +09:00
parent 6cae972c0c
commit 5d3f5dc15d
12 changed files with 483 additions and 19 deletions
@@ -14,9 +14,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import java.util.Arrays;
import java.util.stream.Stream;
@Configuration
@MapperScan(
basePackages = "com.alist.api.modules",
@@ -51,10 +55,18 @@ public class MainDataSourceConfig {
factoryBean.setDataSource(mainDataSource);
factoryBean.setTypeAliasesPackage("com.alist.api");
factoryBean.setConfiguration(mybatisConfiguration());
factoryBean.setMapperLocations(
new PathMatchingResourcePatternResolver()
.getResources("classpath:mapper/*/*.xml")
);
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] commonMappers = resolver.getResources("classpath:mapper/*/*.xml");
Resource[] adminMappers = resolver.getResources("classpath:mapper/admin/**/*.xml");
Resource[] mapperLocations = Stream.concat(
Arrays.stream(commonMappers),
Arrays.stream(adminMappers)
).toArray(Resource[]::new);
factoryBean.setMapperLocations(mapperLocations);
return factoryBean.getObject();
}
@@ -50,6 +50,25 @@ public class SecurityConfig {
@Bean
@Order(2)
public SecurityFilterChain adminFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter
) throws Exception {
http
.securityMatcher("/admin/**")
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/auth/**").permitAll()
.anyRequest().hasRole("ADMIN")
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(3)
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.cors(Customizer.withDefaults())
@@ -7,12 +7,13 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@@ -25,25 +26,50 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String token = resolveToken(request);
String requestUri = request.getRequestURI();
boolean adminRequest = requestUri.startsWith("/admin/");
String token = resolveToken(request, adminRequest);
if (token != null && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserTokenIdx(token);
String principal = jwtTokenProvider.getUserTokenIdx(token);
String role = jwtTokenProvider.getRole(token);
String scope = jwtTokenProvider.getScope(token);
String tokenType = jwtTokenProvider.getTokenType(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
if (adminRequest) {
if (!"ADMIN".equals(scope) || !"ACCESS".equals(tokenType) || !"ADMIN".equals(role)) {
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Admin token required");
return;
}
} else {
if ("ADMIN".equals(scope) || "UPLOAD".equals(scope) || !"ACCESS".equals(tokenType)) {
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access token required");
return;
}
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
principal,
null,
role == null
? List.of()
: List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
SecurityContextHolder.clearContext();
// 로깅은 여기서 해도 됨 (SLF4J)
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
private String resolveToken(HttpServletRequest request, boolean adminRequest) {
// 1) Authorization: Bearer xxx 우선
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
if (bearer != null && bearer.startsWith("Bearer ")) {
@@ -55,13 +81,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 2) 쿠키 fallback (브라우저용)
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
String token = cookie.getValue();
if (token != null && !token.isBlank()) {
return token.trim();
}
if (cookies == null) {
return null;
}
String cookieName = adminRequest ? "adminAccessToken" : "accessToken";
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
String token = cookie.getValue();
if (token != null && !token.isBlank()) {
return token.trim();
}
}
}
@@ -34,6 +34,7 @@ public class JwtTokenProvider {
return Jwts.builder()
.setSubject(userId)
.claim("tokenType", "ACCESS")
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(secretKey, SignatureAlgorithm.HS256)
@@ -48,6 +49,7 @@ public class JwtTokenProvider {
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.claim("role", role)
.claim("tokenType", "ACCESS")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
@@ -61,6 +63,7 @@ public class JwtTokenProvider {
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.claim("tokenType", "REFRESH")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
@@ -90,7 +93,6 @@ public class JwtTokenProvider {
.getBody();
}
/* 업로드 전용 토큰*/
public String createUploadToken(long userTokenIdx) {
Instant now = Instant.now();
@@ -114,4 +116,49 @@ public class JwtTokenProvider {
}
}
public String createAdminAccessToken(long userTokenIdx) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.claim("role", "ADMIN")
.claim("scope", "ADMIN")
.claim("tokenType", "ACCESS")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public String createAdminRefreshToken(long userTokenIdx) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.claim("role", "ADMIN")
.claim("scope", "ADMIN")
.claim("tokenType", "REFRESH")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims getClaims(String token) {
return parseClaims(token);
}
public String getRole(String token) {
return parseClaims(token).get("role", String.class);
}
public String getScope(String token) {
return parseClaims(token).get("scope", String.class);
}
public String getTokenType(String token) {
return parseClaims(token).get("tokenType", String.class);
}
}
@@ -0,0 +1,62 @@
package com.alist.api.modules.admin.auth;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.modules.admin.auth.form.AdminLoginForm;
import com.alist.api.modules.admin.auth.service.AdminAuthService;
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/admin/auth")
public class AdminAuthController {
private final AdminAuthService adminAuthService;
public AdminAuthController(AdminAuthService adminAuthService) {
this.adminAuthService = adminAuthService;
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<AdminLoginVo>> adminLogin(
@Valid @RequestBody AdminLoginForm adminLoginForm
, HttpServletResponse response
) {
AdminLoginVo result = adminAuthService.adminLogin(adminLoginForm.toAdminLoginDto(), response);
if (result.getResultCode() == 2003) {
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
}
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "관리자 로그인");
}
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
HttpServletResponse response
) {
AdminLoginVo result = adminAuthService.adminRefresh(refreshToken, response);
if (result == null) {
return ApiResponse.entity(Map.of("refreshed", false), ApiResponseCode.CODE_401);
}
return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200);
}
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
HttpServletResponse response
) {
adminAuthService.adminLogout(refreshToken, response);
return ApiResponse.entity(Map.of("loggedOut", true), ApiResponseCode.CODE_200);
}
}
@@ -0,0 +1,18 @@
package com.alist.api.modules.admin.auth.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
@Setter
@Getter
public class AdminLoginDto {
private String id;
private String password;
private Integer userIdx;
private Integer userTokenIdx;
private String refreshToken;
private Instant expiresAt;
}
@@ -0,0 +1,24 @@
package com.alist.api.modules.admin.auth.form;
import com.alist.api.modules.admin.auth.dto.AdminLoginDto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AdminLoginForm {
@NotBlank
private String id;
@NotBlank
private String password;
public AdminLoginDto toAdminLoginDto() {
AdminLoginDto adminLoginDto = new AdminLoginDto();
adminLoginDto.setId(id.trim());
adminLoginDto.setPassword(password);
return adminLoginDto;
}
}
@@ -0,0 +1,18 @@
package com.alist.api.modules.admin.auth.mapper;
import com.alist.api.modules.admin.auth.dto.AdminLoginDto;
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AdminAuthMapper {
AdminLoginVo selectAdminLoginById(String id);
void updateAdminRefreshToken(AdminLoginDto adminLoginDto);
void updateAdminLastLoginAt(Integer userIdx);
AdminLoginVo selectAdminTokenByUserTokenIdx(Integer userTokenIdx);
void clearAdminRefreshToken(Integer userTokenIdx);
}
@@ -0,0 +1,153 @@
package com.alist.api.modules.admin.auth.service;
import com.alist.api.common.utils.SessionUtil;
import com.alist.api.config.jwt.JwtTokenProvider;
import com.alist.api.modules.admin.auth.dto.AdminLoginDto;
import com.alist.api.modules.admin.auth.mapper.AdminAuthMapper;
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@Service
public class AdminAuthService {
@Value("${cookie.secure}")
private boolean cookieSecure;
@Value("${cookie.domain:}")
private String cookieDomain;
@Value("${cookie.same-site:Lax}")
private String cookieSameSite;
@Value("${jwt.access-token-validity-seconds}")
private long accessTokenValiditySeconds;
@Value("${jwt.refresh-token-validity-seconds}")
private long refreshTokenValiditySeconds;
private final AdminAuthMapper adminAuthMapper;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
public AdminAuthService(AdminAuthMapper adminAuthMapper, JwtTokenProvider jwtTokenProvider, PasswordEncoder passwordEncoder) {
this.adminAuthMapper = adminAuthMapper;
this.jwtTokenProvider = jwtTokenProvider;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AdminLoginVo adminLogin(AdminLoginDto adminLoginDto, HttpServletResponse response) {
AdminLoginVo result = new AdminLoginVo();
AdminLoginVo adminLoginVo = adminAuthMapper.selectAdminLoginById(adminLoginDto.getId());
if (adminLoginVo == null) {
result.setResultCode(2003);
result.setLogin(false);
return result;
}
if (!passwordEncoder.matches(adminLoginDto.getPassword(), adminLoginVo.getPassword())) {
result.setResultCode(2003);
result.setLogin(false);
return result;
}
String accessToken = jwtTokenProvider.createAdminAccessToken(adminLoginVo.getUserTokenIdx());
String refreshToken = jwtTokenProvider.createAdminRefreshToken(adminLoginVo.getUserTokenIdx());
adminLoginDto.setUserIdx(adminLoginVo.getUserIdx());
adminLoginDto.setUserTokenIdx(adminLoginVo.getUserTokenIdx());
adminLoginDto.setRefreshToken(refreshToken);
adminLoginDto.setExpiresAt(Instant.now().plusSeconds(refreshTokenValiditySeconds));
adminAuthMapper.updateAdminRefreshToken(adminLoginDto);
adminAuthMapper.updateAdminLastLoginAt(adminLoginDto.getUserIdx());
SessionUtil.addTokenCookie(response,"adminAccessToken", accessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
SessionUtil.addTokenCookie(response,"adminRefreshToken", refreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
adminLoginVo.setResultCode(2001);
adminLoginVo.setLogin(true);
return adminLoginVo;
}
public AdminLoginVo adminRefresh(String refreshToken, HttpServletResponse response) {
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
return null;
}
String role = jwtTokenProvider.getRole(refreshToken);
String scope = jwtTokenProvider.getScope(refreshToken);
String tokenType = jwtTokenProvider.getTokenType(refreshToken);
if (!"ADMIN".equals(role) || !"ADMIN".equals(scope) || !"REFRESH".equals(tokenType)) {
return null;
}
Integer userTokenIdx;
try {
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
} catch (NumberFormatException e) {
return null;
}
AdminLoginVo adminToken = adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx);
if (adminToken == null || adminToken.getRefreshToken() == null) {
return null;
}
if (!refreshToken.equals(adminToken.getRefreshToken())) {
return null;
}
String newAccessToken = jwtTokenProvider.createAdminAccessToken(userTokenIdx);
String newRefreshToken = jwtTokenProvider.createAdminRefreshToken(userTokenIdx);
AdminLoginDto adminLoginDto = new AdminLoginDto();
adminLoginDto.setUserTokenIdx(userTokenIdx);
adminLoginDto.setRefreshToken(newRefreshToken);
adminLoginDto.setExpiresAt(Instant.now().plusSeconds(refreshTokenValiditySeconds));
adminAuthMapper.updateAdminRefreshToken(adminLoginDto);
SessionUtil.addTokenCookie(response, "adminAccessToken", newAccessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
SessionUtil.addTokenCookie(response, "adminRefreshToken", newRefreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
adminToken.setResultCode(2001);
return adminToken;
}
public void adminLogout(String refreshToken, HttpServletResponse response) {
clearAdminRefreshToken(refreshToken);
SessionUtil.expireCookie(response, "adminAccessToken", cookieDomain, cookieSecure, cookieSameSite);
SessionUtil.expireCookie(response, "adminRefreshToken", cookieDomain, cookieSecure, cookieSameSite);
}
private void clearAdminRefreshToken(String refreshToken) {
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
return;
}
String role = jwtTokenProvider.getRole(refreshToken);
String scope = jwtTokenProvider.getScope(refreshToken);
String tokenType = jwtTokenProvider.getTokenType(refreshToken);
if (!"ADMIN".equals(role) || !"ADMIN".equals(scope) || !"REFRESH".equals(tokenType)) {
return;
}
try {
Integer userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
adminAuthMapper.clearAdminRefreshToken(userTokenIdx);
} catch (NumberFormatException ignored) {
}
}
}
@@ -0,0 +1,25 @@
package com.alist.api.modules.admin.auth.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class AdminLoginVo {
private boolean isLogin;
private Integer userIdx;
private String id;
@JsonIgnore
private String password;
@JsonIgnore
private Integer userTokenIdx;
@JsonIgnore
private String refreshToken;
@JsonIgnore
private int resultCode;
}
+1 -1
View File
@@ -46,7 +46,7 @@ jwt:
cookie:
secure: false # (HTTP) -> false
domain: api-alist.pjt.kr # (HTTP) -> 비워두세요
domain: # (HTTP) -> 비워두세요
name: ALIST_SSO
same-site: Lax # (HTTP) -> Lax
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.alist.api.modules.admin.auth.mapper.AdminAuthMapper">
<update id="updateAdminRefreshToken">
/*AdminLoginMapper.updateAdminRefreshToken*/
UPDATE ALISTLMS.test_user_token
SET refresh_token = #{refreshToken}
, expires_at = #{expiresAt}
, updated_at = now()
WHERE user_token_idx = #{userTokenIdx}
AND user_role = 'admin'
</update>
<update id="updateAdminLastLoginAt">
/*AdminLoginMapper.updateAdminLastLoginAt*/
UPDATE ALISTLMS.test_user
SET last_login_at = now()
, update_at = now()
WHERE user_idx = #{userIdx}
</update>
<update id="clearAdminRefreshToken">
/*AdminLoginMapper.clearAdminRefreshToken*/
UPDATE ALISTLMS.test_user_token
SET refresh_token = null
, expires_at = null
, updated_at = now()
WHERE user_token_idx = #{userTokenIdx}
AND user_role = 'admin'
</update>
<select id="selectAdminLoginById" resultType="com.alist.api.modules.admin.auth.vo.AdminLoginVo">
/*AdminLoginMapper.selectAdminLoginById*/
SELECT ATU.user_idx
, ATU.id
, ATU.password
, ATUT.user_token_idx
FROM ALISTLMS.test_user ATU
INNER JOIN ALISTLMS.test_user_token ATUT ON ATUT.user_idx = ATU.user_idx
WHERE ATU.id = #{id}
AND ATU.del_yn = 1
AND ATUT.user_role = 'admin'
LIMIT 1
</select>
<select id="selectAdminTokenByUserTokenIdx" resultType="com.alist.api.modules.admin.auth.vo.AdminLoginVo">
/*AdminLoginMapper.selectAdminTokenByUserTokenIdx*/
SELECT ATU.user_idx
, ATU.id
, ATUT.user_token_idx
, ATUT.refresh_token
FROM ALISTLMS.test_user_token ATUT
INNER JOIN ALISTLMS.test_user ATU ON ATU.user_idx = ATUT.user_idx
WHERE ATUT.user_token_idx = #{userTokenIdx}
AND ATUT.user_role = 'admin'
AND ATU.del_yn = 1
LIMIT 1
</select>
</mapper>