[API] SSO 다중 도메인 로그인 작업 커밋

This commit is contained in:
2026-03-31 11:55:23 +09:00
parent 6e58d57821
commit 07683e843d
24 changed files with 699 additions and 57 deletions
@@ -0,0 +1,19 @@
package com.alist.api.common.utils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityUtil {
public static Integer getLoginUserTokenIdx() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
return null;
}
try {
return Integer.parseInt(authentication.getPrincipal().toString());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -1,33 +1,55 @@
package com.alist.api.common.utils; package com.alist.api.common.utils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseCookie;
public class SessionUtil { public class SessionUtil {
// 쿠키 삭제 // 쿠키 삭제
public static void expireCookie(HttpServletResponse response, String name, String cookieDomain, boolean cookieSecure, String cookieSameSite) { public static void expireCookie(HttpServletResponse response, String name, String cookieDomain, boolean cookieSecure, String cookieSameSite) {
ResponseCookie cookie = ResponseCookie.from(name, "") ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "")
.domain(cookieDomain)
.path("/") .path("/")
.httpOnly(true) .httpOnly(true)
.secure(cookieSecure) .secure(cookieSecure)
.sameSite(cookieSameSite) .sameSite(cookieSameSite)
.maxAge(0) .maxAge(0);
.build();
response.addHeader("Set-Cookie", cookie.toString()); if (cookieDomain != null && !cookieDomain.isBlank()) {
builder.domain(cookieDomain.trim());
}
response.addHeader("Set-Cookie", builder.build().toString());
} }
// 쿠키 입력 // 쿠키 입력
public static void addTokenCookie(HttpServletResponse response, String name, String value, String cookieDomain, boolean cookieSecure, String cookieSameSite, long maxAgeSeconds) { public static void addTokenCookie(HttpServletResponse response, String name, String value, String cookieDomain, boolean cookieSecure, String cookieSameSite, long maxAgeSeconds) {
ResponseCookie cookie = ResponseCookie.from(name, value) ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value)
.domain(cookieDomain)
.path("/") .path("/")
.httpOnly(true) .httpOnly(true)
.secure(cookieSecure) .secure(cookieSecure)
.sameSite(cookieSameSite) .sameSite(cookieSameSite)
.maxAge(maxAgeSeconds) .maxAge(maxAgeSeconds);
.build();
response.addHeader("Set-Cookie", cookie.toString()); if (cookieDomain != null && !cookieDomain.isBlank()) {
builder.domain(cookieDomain.trim());
}
response.addHeader("Set-Cookie", builder.build().toString());
}
// 쿠키에서 특정 이름 값 찾기
public static String resolveSsoCookieValue(HttpServletRequest request, String cookieName) {
if (request.getCookies() == null) {
return null;
}
for (Cookie cookie : request.getCookies()) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
} }
} }
@@ -59,7 +59,7 @@ public class SecurityConfig {
.accessDeniedHandler(new JwtAccessDeniedHandler()) .accessDeniedHandler(new JwtAccessDeniedHandler())
) )
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/actuator/health", "/auth/**", "/test/**", "/files/tusHook").permitAll() .requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/test/**", "/files/tusHook").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -28,7 +28,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String token = resolveToken(request); String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) { if (token != null && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token); String userId = jwtTokenProvider.getUserTokenIdx(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
@@ -41,7 +41,7 @@ public class JwtTokenProvider {
} }
/* 엑세스 토큰 생성 */ /* 엑세스 토큰 생성 */
public String createAccessToken(long userTokenIdx, String role) { public String createAccessToken(Integer userTokenIdx, String role) {
Instant now = Instant.now(); Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds()); Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
@@ -55,7 +55,7 @@ public class JwtTokenProvider {
} }
/* 리프레시 토큰 생성 */ /* 리프레시 토큰 생성 */
public String createRefreshToken(long userTokenIdx) { public String createRefreshToken(Integer userTokenIdx) {
Instant now = Instant.now(); Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds()); Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
@@ -68,7 +68,7 @@ public class JwtTokenProvider {
} }
/** 토큰에서 subject 추출 */ /** 토큰에서 subject 추출 */
public String getUserId(String token) { public String getUserTokenIdx(String token) {
return parseClaims(token).getSubject(); return parseClaims(token).getSubject();
} }
@@ -1,8 +1,10 @@
package com.alist.api.modules.auth; package com.alist.api.modules.auth;
import com.alist.api.common.utils.SessionUtil; import com.alist.api.common.utils.SessionUtil;
import com.alist.api.modules.auth.dto.SsoExchangeDto;
import com.alist.api.modules.auth.dto.TestLoginDto; import com.alist.api.modules.auth.dto.TestLoginDto;
import com.alist.api.modules.auth.dto.TokenDto; import com.alist.api.modules.auth.dto.TokenDto;
import com.alist.api.modules.auth.service.SsoService;
import com.alist.api.modules.auth.service.TestAuthService; import com.alist.api.modules.auth.service.TestAuthService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -46,10 +48,16 @@ public class AuthController {
@Value("${jwt.refresh-token-validity-seconds}") @Value("${jwt.refresh-token-validity-seconds}")
private long refreshTokenValiditySeconds; private long refreshTokenValiditySeconds;
@Value("${sso.cookie.name}")
private String ssoCookieName;
private final SsoService ssoService;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final TestAuthService testAuthService; private final TestAuthService testAuthService;
public AuthController(JwtTokenProvider jwtTokenProvider, TestAuthService testAuthService) { public AuthController(SsoService ssoService, JwtTokenProvider jwtTokenProvider, TestAuthService testAuthService) {
this.ssoService = ssoService;
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.testAuthService = testAuthService; this.testAuthService = testAuthService;
} }
@@ -66,6 +74,43 @@ public class AuthController {
return ApiResponse.entity(tokenDto, ApiResponseCode.CODE_2001, "엑세스 토큰"); return ApiResponse.entity(tokenDto, ApiResponseCode.CODE_2001, "엑세스 토큰");
} }
@Operation(
summary = "SSO 기반 토큰 발급",
description = "ALIST_SSO 쿠키 기준으로 SSO 로그인 상태를 확인한 뒤 accessToken, refreshToken 쿠키를 발급합니다."
)
@PostMapping("/access")
public ResponseEntity<ApiResponse<Map<String, Object>>> access(
HttpServletRequest request,
HttpServletResponse response
) {
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
SsoExchangeDto ssoSession = ssoService.loginChecked(ssoSessionId);
if (ssoSession == null) {
return ApiResponse.entity(Map.of("isAccessToken", false), ApiResponseCode.CODE_401);
}
String accessToken = jwtTokenProvider.createAccessToken(ssoSession.getUserTokenIdx(), ssoSession.getUserRole());
String refreshToken = jwtTokenProvider.createRefreshToken(ssoSession.getUserTokenIdx());
TestLoginDto testLoginDto = new TestLoginDto();
testLoginDto.setUserTokenIdx(ssoSession.getUserTokenIdx());
testLoginDto.setRefreshToken(refreshToken);
testAuthService.updateRefreshToken(testLoginDto);
SessionUtil.addTokenCookie(response, "accessToken", accessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
SessionUtil.addTokenCookie(response,"refreshToken", refreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
Map<String, Object> result = new HashMap<>();
result.put("isAccessToken", true);
result.put("userIdx", ssoSession.getUserIdx());
result.put("userId", ssoSession.getUserId());
result.put("userRole", ssoSession.getUserRole());
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "토큰");
}
@Operation( @Operation(
summary = "리프레시 토큰으로 엑세스 토큰 재발급", summary = "리프레시 토큰으로 엑세스 토큰 재발급",
description = "리프레시 토큰으로 엑세스 토큰 재발급" description = "리프레시 토큰으로 엑세스 토큰 재발급"
@@ -0,0 +1,167 @@
package com.alist.api.modules.auth;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.common.utils.SessionUtil;
import com.alist.api.modules.auth.dto.SsoExchangeDto;
import com.alist.api.modules.auth.dto.SsoLoginCheckDto;
import com.alist.api.modules.auth.dto.SsoLoginDto;
import com.alist.api.modules.auth.form.SsoExchangeForm;
import com.alist.api.modules.auth.form.SsoLoginForm;
import com.alist.api.modules.auth.service.SsoService;
import com.alist.api.modules.auth.service.TestAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "02. SSO 인증", description = "공통 로그인, 인가 코드 발급, 코드 교환, 로그아웃 관련 API")
@RestController
@RequestMapping("/sso")
public class SsoController {
@Value("${cookie.secure}")
private boolean cookieSecure;
@Value("${cookie.domain:}")
private String cookieDomain;
@Value("${cookie.same-site:Lax}")
private String cookieSameSite;
@Value("${sso.session.ttl-seconds}")
private long ssoSessionTtlSeconds;
@Value("${sso.cookie.name}")
private String ssoCookieName;
private final SsoService ssoService;
private final TestAuthService testAuthService;
public SsoController(SsoService ssoService, TestAuthService testAuthService) {
this.ssoService = ssoService;
this.testAuthService = testAuthService;
}
@Operation(
summary = "SSO 로그인",
description = "아이디/비밀번호로 공통 SSO 로그인 상태를 생성하고 HttpOnly 쿠키를 발급합니다."
)
@PostMapping("/login")
public ResponseEntity<ApiResponse<SsoLoginCheckDto>> login(
@Valid @RequestBody SsoLoginForm ssoLoginForm
, HttpServletRequest request
, HttpServletResponse response
) {
SsoLoginDto ssoLoginDto = ssoService.login(ssoLoginForm.ssoLoginDto(), request);
SsoLoginCheckDto result = new SsoLoginCheckDto();
if (ssoLoginDto.getResultCode() == 2003) {
result.setLoginIn(false);
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
}
SessionUtil.addTokenCookie(response, ssoCookieName, ssoLoginDto.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, ssoSessionTtlSeconds);
result.setLoginIn(true);
result.setId(ssoLoginDto.getUserId());
result.setUserIdx(ssoLoginDto.getUserIdx());
result.setRole(ssoLoginDto.getUserRole());
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "SSO 로그인");
}
@Operation(
summary = "SSO 로그인 상태 확인",
description = "SSO 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인합니다."
)
@GetMapping("/loginChecked")
public ResponseEntity<ApiResponse<Map<String, Object>>> loginChecked(
HttpServletRequest request
, HttpServletResponse response
) {
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
Map<String, Object> result = new HashMap<>();
SsoExchangeDto ssoSession = ssoService.loginChecked(ssoSessionId);
if (ssoSession == null) {
result.put("loggedIn", false);
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
SessionUtil.addTokenCookie(response, ssoCookieName, ssoSession.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite,ssoSessionTtlSeconds);
result.put("loggedIn", true);
result.put("ssoSessionId", ssoSession.getSsoSessionId());
result.put("userId", ssoSession.getUserId());
result.put("userIdx", ssoSession.getUserIdx());
result.put("userTokenIdx", ssoSession.getUserTokenIdx());
result.put("userRole", ssoSession.getUserRole());
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
@Operation(
summary = "SSO 인가 코드 발급",
description = "공통 로그인 상태를 확인한 뒤 대상 서비스로 이동할 1회용 인가 코드를 발급하고 redirectUri 로 리다이렉트합니다."
)
@GetMapping("/authorize")
public ResponseEntity<Void> authorize(
@RequestParam("clientId") String clientId
, @RequestParam("redirectUri") String redirectUri
, @RequestParam(value = "state", required = false) String state
, HttpServletRequest request
) {
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
String redirectUrl = ssoService.authorize(clientId, redirectUri, state, ssoSessionId);
return ResponseEntity.status(302).location(URI.create(redirectUrl)).build();
}
@Operation(
summary = "SSO 인가 코드 교환",
description = "서비스가 전달받은 1회용 인가 코드를 검증하고, 자체 세션 생성에 필요한 사용자 정보를 반환합니다."
)
@PostMapping("/exchange")
public ResponseEntity<ApiResponse<SsoExchangeDto>> exchange(
@Valid @RequestBody SsoExchangeForm ssoExchangeForm
, HttpServletResponse response
) {
SsoExchangeDto result = ssoService.exchange(ssoExchangeForm.ssoExchangeDto());
SessionUtil.addTokenCookie(response, ssoCookieName, result.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, ssoSessionTtlSeconds);
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "SSO code 교환");
}
@Operation(
summary = "SSO 로그아웃",
description = "공통 로그인 세션을 만료시켜 이후 authorize 및 서비스 접근 시 재로그인이 필요하도록 처리합니다."
)
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
HttpServletRequest request
, HttpServletResponse response
, @CookieValue(name = "refreshToken", required = false) String refreshToken
) {
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
ssoService.logout(ssoSessionId);
testAuthService.clearRefreshToken(refreshToken);
SessionUtil.expireCookie(response, ssoCookieName, cookieDomain, cookieSecure, cookieSameSite);
SessionUtil.expireCookie(response, "accessToken", cookieDomain, cookieSecure, cookieSameSite);
SessionUtil.expireCookie(response, "refreshToken", cookieDomain, cookieSecure, cookieSameSite);
return ApiResponse.entity(Map.of("logout", true), ApiResponseCode.CODE_200);
}
}
@@ -0,0 +1,18 @@
package com.alist.api.modules.auth.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SsoExchangeDto {
private String code;
private String clientId;
private String ssoSessionId;
private Integer userIdx;
private Integer userTokenIdx;
private String userId;
private String userRole;
}
@@ -0,0 +1,13 @@
package com.alist.api.modules.auth.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SsoLoginCheckDto {
private boolean loginIn;
private String id;
private Integer userIdx;
private String role;
}
@@ -0,0 +1,26 @@
package com.alist.api.modules.auth.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SsoLoginDto {
private String id;
private String password;
private int resultCode;
private String ssoSessionId;
private Integer userIdx;
private Integer userTokenIdx;
private String userId;
private String userRole;
public TestLoginDto testLoginDto() {
TestLoginDto testLoginDto = new TestLoginDto();
testLoginDto.setId(this.id);
testLoginDto.setPassword(this.password);
return testLoginDto;
}
}
@@ -0,0 +1,21 @@
package com.alist.api.modules.auth.form;
import com.alist.api.modules.auth.dto.SsoExchangeDto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class SsoExchangeForm {
@NotBlank
private String code;
@NotBlank
private String clientId;
public SsoExchangeDto ssoExchangeDto() {
SsoExchangeDto ssoExchangeDto = new SsoExchangeDto();
ssoExchangeDto.setCode(code == null ? null : code.trim());
ssoExchangeDto.setClientId(clientId == null ? null : clientId.trim());
return ssoExchangeDto;
}
}
@@ -0,0 +1,21 @@
package com.alist.api.modules.auth.form;
import com.alist.api.modules.auth.dto.SsoLoginDto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class SsoLoginForm {
@NotBlank
private String id;
@NotBlank
private String password;
public SsoLoginDto ssoLoginDto() {
SsoLoginDto ssoLoginDto = new SsoLoginDto();
ssoLoginDto.setId(id == null ? null : id.trim());
ssoLoginDto.setPassword(password == null ? null : password.trim());
return ssoLoginDto;
}
}
@@ -0,0 +1,9 @@
package com.alist.api.modules.auth.mapper;
import com.alist.api.modules.auth.vo.SsoClientVo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SsoMapper {
SsoClientVo selectSsoClient(String clientId, String redirectUri);
}
@@ -0,0 +1,217 @@
package com.alist.api.modules.auth.service;
import com.alist.api.modules.auth.dto.SsoExchangeDto;
import com.alist.api.modules.auth.dto.SsoLoginDto;
import com.alist.api.modules.auth.dto.TestLoginDto;
import com.alist.api.modules.auth.mapper.SsoMapper;
import com.alist.api.modules.auth.vo.SsoClientVo;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
@Service
public class SsoService {
private final TestAuthService testAuthService;
private final SsoMapper ssoMapper;
private final StringRedisTemplate redisTemplate;
private final Duration ssoSessionTtl;
private final Duration ssoCodeTtl;
public SsoService(TestAuthService testAuthService, SsoMapper ssoMapper, StringRedisTemplate redisTemplate, @Value("${sso.session.ttl-seconds}") long ssoSessionTtlSeconds, @Value("${sso.code.ttl-seconds}") long ssoCodeTtlSeconds) {
this.testAuthService = testAuthService;
this.ssoMapper = ssoMapper;
this.redisTemplate = redisTemplate;
this.ssoSessionTtl = Duration.ofSeconds(ssoSessionTtlSeconds);
this.ssoCodeTtl = Duration.ofSeconds(ssoCodeTtlSeconds);
}
public SsoLoginDto login(SsoLoginDto loginDto, HttpServletRequest request) {
TestLoginDto testLoginDto = testAuthService.selectTestLogin(loginDto.testLoginDto());
SsoLoginDto result = new SsoLoginDto();
result.setResultCode(testLoginDto.getResultCode());
if (testLoginDto.getResultCode() == 2003) {
return result;
}
String ssoSessionId = "SSO_" + UUID.randomUUID();
String latestSessionKey = "alist:sso:userIdx:" + testLoginDto.getUserIdx();
String sessionKey = "alist:sso:session:" + ssoSessionId;
String oldSsoSessionId = redisTemplate.opsForValue().get(latestSessionKey);
if (oldSsoSessionId != null && !oldSsoSessionId.isBlank()) {
redisTemplate.delete("alist:sso:session:" + oldSsoSessionId);
}
redisTemplate.opsForHash().put(sessionKey, "userIdx", String.valueOf(testLoginDto.getUserIdx()));
redisTemplate.opsForHash().put(sessionKey, "userTokenIdx", String.valueOf(testLoginDto.getUserTokenIdx()));
redisTemplate.opsForHash().put(sessionKey, "userId", testLoginDto.getId());
redisTemplate.opsForHash().put(sessionKey, "userRole", "USER");
redisTemplate.expire(sessionKey, ssoSessionTtl);
redisTemplate.opsForValue().set(latestSessionKey, ssoSessionId, ssoSessionTtl);
result.setSsoSessionId(ssoSessionId);
result.setUserId(testLoginDto.getId());
result.setUserIdx(testLoginDto.getUserIdx());
result.setUserRole("USER");
result.setResultCode(200);
return result;
}
public SsoExchangeDto loginChecked(String ssoSessionId) {
if (ssoSessionId == null || ssoSessionId.isBlank()) {
return null;
}
String sessionKey = "alist:sso:session:" + ssoSessionId;
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
if (userIdx == null || userIdx.isBlank()) {
return null;
}
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
if (!ssoSessionId.equals(latestSsoSessionId)) {
return null;
}
refreshSsoSessionTtl(ssoSessionId, userIdx);
String userId = (String) redisTemplate.opsForHash().get(sessionKey, "userId");
String userTokenIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userTokenIdx");
String userRole = (String) redisTemplate.opsForHash().get(sessionKey, "userRole");
SsoExchangeDto result = new SsoExchangeDto();
result.setSsoSessionId(ssoSessionId);
result.setUserId(userId);
result.setUserIdx(userIdx == null ? null : Integer.parseInt(userIdx));
result.setUserTokenIdx(userTokenIdx == null ? null : Integer.parseInt(userTokenIdx));
result.setUserRole(userRole);
return result;
}
public String authorize(String clientId, String redirectUri, String state, String ssoSessionId) {
if (ssoSessionId == null || ssoSessionId.isBlank()) {
return "/login";
}
String sessionKey = "alist:sso:session:" + ssoSessionId;
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
if (userIdx == null) {
return "/login";
}
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
if (!ssoSessionId.equals(latestSsoSessionId)) {
return "/login";
}
SsoClientVo ssoClientVo = ssoMapper.selectSsoClient(clientId, redirectUri);
if (ssoClientVo == null) {
throw new IllegalArgumentException("허용되지 않은 clientId 또는 redirectUri 입니다.");
}
if (!"Y".equalsIgnoreCase(ssoClientVo.getUseYn())) {
throw new IllegalArgumentException("비활성화된 SSO 클라이언트입니다.");
}
refreshSsoSessionTtl(ssoSessionId, userIdx);
String code = "CODE_" + UUID.randomUUID();
String codeKey = "alist:sso:code:" + code;
redisTemplate.opsForHash().put(codeKey, "ssoSessionId", ssoSessionId);
redisTemplate.opsForHash().put(codeKey, "clientId", clientId);
redisTemplate.opsForHash().put(codeKey, "redirectUri", redirectUri);
redisTemplate.expire(codeKey, ssoCodeTtl);
String encodedCode = URLEncoder.encode(code, StandardCharsets.UTF_8);
String encodedState = state == null ? "" : "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8);
URI uri = URI.create(redirectUri);
String base = uri.getScheme() + "://" + uri.getAuthority() + uri.getPath();
String query = uri.getQuery();
StringBuilder redirect = new StringBuilder(base);
if (query == null || query.isBlank()) {
redirect.append("?code=").append(encodedCode);
} else {
redirect.append("?").append(query).append("&code=").append(encodedCode);
}
redirect.append(encodedState);
return redirect.toString();
}
public SsoExchangeDto exchange(SsoExchangeDto ssoExchangeDto) {
String codeKey = "alist:sso:code:" + ssoExchangeDto.getCode();
String ssoSessionId = (String) redisTemplate.opsForHash().get(codeKey, "ssoSessionId");
String clientId = (String) redisTemplate.opsForHash().get(codeKey, "clientId");
if (ssoSessionId == null || !ssoExchangeDto.getClientId().equals(clientId)) {
throw new IllegalArgumentException("유효하지 않은 code 입니다.");
}
String sessionKey = "alist:sso:session:" + ssoSessionId;
String userId = (String) redisTemplate.opsForHash().get(sessionKey, "userId");
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
String userTokenIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userTokenIdx");
String userRole = (String) redisTemplate.opsForHash().get(sessionKey, "userRole");
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
if (!ssoSessionId.equals(latestSsoSessionId)) {
throw new IllegalArgumentException("다른 곳에서 다시 로그인되어 세션이 만료되었습니다.");
}
refreshSsoSessionTtl(ssoSessionId, userIdx);
redisTemplate.delete(codeKey);
SsoExchangeDto result = new SsoExchangeDto();
result.setUserId(userId);
result.setUserIdx(Integer.parseInt(userIdx));
result.setUserTokenIdx(Integer.parseInt(userTokenIdx));
result.setUserRole(userRole);
result.setSsoSessionId(ssoSessionId);
return result;
}
public void logout(String ssoSessionId) {
if (ssoSessionId == null || ssoSessionId.isBlank()) {
return;
}
String sessionKey = "alist:sso:session:" + ssoSessionId;
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
redisTemplate.delete(sessionKey);
if (userIdx != null) {
redisTemplate.delete("alist:sso:userIdx:" + userIdx);
}
}
private void refreshSsoSessionTtl(String ssoSessionId, String userIdx) {
if (ssoSessionId == null || ssoSessionId.isBlank() || userIdx == null || userIdx.isBlank()) {
return;
}
String sessionKey = "alist:sso:session:" + ssoSessionId;
String latestSessionKey = "alist:sso:userIdx:" + userIdx;
redisTemplate.expire(sessionKey, ssoSessionTtl);
redisTemplate.expire(latestSessionKey, ssoSessionTtl);
}
}
@@ -9,6 +9,7 @@ import com.alist.api.modules.auth.vo.TestLoginVo;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
@@ -95,7 +96,7 @@ public class TestAuthService {
int userTokenIdx; int userTokenIdx;
try { try {
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken)); userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
return null; return null;
} }
@@ -125,9 +126,19 @@ public class TestAuthService {
} }
try { try {
int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken)); int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
testAuthMapper.clearRefreshTokenByUserTokenIdx(userTokenIdx); testAuthMapper.clearRefreshTokenByUserTokenIdx(userTokenIdx);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
} }
@Transactional
public void updateRefreshToken(TestLoginDto testLoginDto) {
Instant now = Instant.now();
Instant expiresAt = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
testLoginDto.setExpiresAt(expiresAt);
testAuthMapper.updateRefreshToken(testLoginDto);
}
} }
@@ -0,0 +1,13 @@
package com.alist.api.modules.auth.vo;
import lombok.Getter;
@Getter
public class SsoClientVo {
private Long ssoClientIdx;
private String clientId;
private String clientName;
private String redirectUri;
private String memo;
private String useYn;
}
@@ -1,5 +1,6 @@
package com.alist.api.modules.file; package com.alist.api.modules.file;
import com.alist.api.common.utils.SecurityUtil;
import com.alist.api.modules.file.dto.*; import com.alist.api.modules.file.dto.*;
import com.alist.api.modules.file.form.*; import com.alist.api.modules.file.form.*;
import com.alist.api.common.response.ApiResponse; import com.alist.api.common.response.ApiResponse;
@@ -42,15 +43,18 @@ public class FileController {
@PostMapping("/uploadInit") @PostMapping("/uploadInit")
public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit( public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit(
@Valid @RequestBody FileUploadForm fileUploadForm @Valid @RequestBody FileUploadForm fileUploadForm
, HttpServletRequest request
) { ) {
FileUploadDto fileUploadDto = fileUploadForm.fileUploadDto(); FileUploadDto fileUploadDto = fileUploadForm.fileUploadDto();
HttpSession session = request.getSession(false); Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
Integer userTokenIdx = (session == null) ? null : (Integer) session.getAttribute("userTokenIdx");
Integer userIdx = (session == null) ? null : (Integer) session.getAttribute("userIdx");
if (userTokenIdx == null || userIdx == null) { if (userTokenIdx == null) {
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
}
Integer userIdx = fileService.selectUserIdxByUserTokenIdx(userTokenIdx);
if (userIdx == null) {
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401); return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
} }
@@ -134,10 +138,8 @@ public class FileController {
@PostMapping("/uploadCancel") @PostMapping("/uploadCancel")
public ResponseEntity<ApiResponse<String>> uploadCancel( public ResponseEntity<ApiResponse<String>> uploadCancel(
@Valid @RequestBody UploadCancelForm uploadCancelForm @Valid @RequestBody UploadCancelForm uploadCancelForm
, HttpServletRequest request
) { ) {
HttpSession session = request.getSession(false); Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
Integer userTokenIdx = (session == null) ? null : (Integer) session.getAttribute("userTokenIdx");
if (userTokenIdx == null) { if (userTokenIdx == null) {
return ApiResponse.entity("", ApiResponseCode.CODE_401); return ApiResponse.entity("", ApiResponseCode.CODE_401);
@@ -48,4 +48,6 @@ public interface FileMapper {
int updateFileDetailDeleteByFileUuid(FileDeleteDto fileDeleteDto); int updateFileDetailDeleteByFileUuid(FileDeleteDto fileDeleteDto);
int updateFileMasterDeleteByFileUuid(FileDeleteDto fileDeleteDto); int updateFileMasterDeleteByFileUuid(FileDeleteDto fileDeleteDto);
Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx);
} }
@@ -1,14 +1,13 @@
package com.alist.api.modules.file.service; package com.alist.api.modules.file.service;
import com.alist.api.common.utils.SecurityUtil;
import com.alist.api.config.jwt.JwtTokenProvider;
import com.alist.api.modules.file.dto.*; import com.alist.api.modules.file.dto.*;
import com.alist.api.modules.file.mapper.FileMapper; import com.alist.api.modules.file.mapper.FileMapper;
import com.alist.api.config.jwt.JwtTokenProvider;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -94,7 +93,7 @@ public class FileService {
} }
try { try {
uploadTokenDto.setUserTokenIdx(Integer.parseInt(jwtTokenProvider.getUserId(uploadTokenDto.getUploadToken()))); uploadTokenDto.setUserTokenIdx(Integer.parseInt(jwtTokenProvider.getUserTokenIdx(uploadTokenDto.getUploadToken())));
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
uploadTokenDto.setResultCode(401); uploadTokenDto.setResultCode(401);
return uploadTokenDto; return uploadTokenDto;
@@ -499,7 +498,7 @@ public class FileService {
} }
private String buildUploadStatusKey(String fileUuid) { private String buildUploadStatusKey(String fileUuid) {
return "upload:alist:status:" + fileUuid; return "alist:upload:status:" + fileUuid;
} }
private long parseLong(Object value) { private long parseLong(Object value) {
@@ -524,7 +523,7 @@ public class FileService {
normalizedSize normalizedSize
); );
return "upload:alist:auth:" + sha256(raw); return "alist:upload:auth:" + sha256(raw);
} }
private String normalize(String v) { private String normalize(String v) {
@@ -587,24 +586,11 @@ public class FileService {
return true; return true;
} }
private Integer getLoginUserTokenIdx() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
return null;
}
try {
return Integer.parseInt(authentication.getPrincipal().toString());
} catch (NumberFormatException e) {
return null;
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileDownloadListDto selectFileDownloadListByFileMasterIdx(FileDownloadDto fileDownloadDto) { public FileDownloadListDto selectFileDownloadListByFileMasterIdx(FileDownloadDto fileDownloadDto) {
FileDownloadListDto fileDownloadList = new FileDownloadListDto(); FileDownloadListDto fileDownloadList = new FileDownloadListDto();
Integer userTokenIdx = getLoginUserTokenIdx(); Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
if (userTokenIdx == null) { if (userTokenIdx == null) {
fileDownloadList.setResultCode(401); fileDownloadList.setResultCode(401);
return fileDownloadList; return fileDownloadList;
@@ -628,7 +614,7 @@ public class FileService {
} }
public FileDownloadDto selectFileViewOrDownload(FileDownloadDto fileDownloadDto) { public FileDownloadDto selectFileViewOrDownload(FileDownloadDto fileDownloadDto) {
Integer userTokenIdx = getLoginUserTokenIdx(); Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
if (userTokenIdx == null) { if (userTokenIdx == null) {
return null; return null;
} }
@@ -674,7 +660,7 @@ public class FileService {
return false; return false;
} }
Integer userTokenIdx = getLoginUserTokenIdx(); Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
if (userTokenIdx == null) { if (userTokenIdx == null) {
return false; return false;
} }
@@ -695,4 +681,12 @@ public class FileService {
fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto); fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
return true; return true;
} }
@Transactional(readOnly = true)
public Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx) {
if (userTokenIdx == null) {
return null;
}
return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
}
} }
+13 -5
View File
@@ -33,10 +33,10 @@ jwt:
refresh-token-validity-seconds: 2592000 refresh-token-validity-seconds: 2592000
cookie: cookie:
secure: false # 로컬 개발 환경 (HTTP) secure: true # 로컬 개발 환경 (HTTP)
domain: domain: api-alist.pjt.kr
name: ALIST_SESSION name: ALIST_SSO
same-site: Lax same-site: None
swagger: swagger:
login: login:
@@ -57,4 +57,12 @@ springdoc:
api-docs: api-docs:
enabled: true enabled: true
swagger-ui: swagger-ui:
enabled: true enabled: true
sso:
session:
ttl-seconds: 7200
code:
ttl-seconds: 300
cookie:
name: ALIST_SSO
+11 -3
View File
@@ -36,8 +36,8 @@ jwt:
cookie: cookie:
secure: true # 프로젝트 환경 (HTTPS) secure: true # 프로젝트 환경 (HTTPS)
domain: pjt.kr # sso 인증시 주석해제 domain: api-alist.pjt.kr # sso 인증시 주석해제
name: ALIST_SESSION name: ALIST_SSO
same-site: None same-site: None
swagger: swagger:
@@ -59,4 +59,12 @@ springdoc:
api-docs: api-docs:
enabled: true enabled: true
swagger-ui: swagger-ui:
enabled: true enabled: true
sso:
session:
ttl-seconds: 7200
code:
ttl-seconds: 300
cookie:
name: ALIST_SSO
@@ -0,0 +1,18 @@
<?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.auth.mapper.SsoMapper">
<select id="selectSsoClient" resultType="com.alist.api.modules.auth.vo.SsoClientVo">
/*SsoMapper.selectSsoClient*/
SELECT SSO_CLIENT_IDX
, CLIENT_ID
, CLIENT_NAME
, REDIRECT_URI
, MEMO
, USE_YN
FROM SSO_CLIENT
WHERE CLIENT_ID = #{clientId}
AND USE_YN = 'Y'
LIMIT 1
</select>
</mapper>
@@ -315,8 +315,14 @@
AND LFD.STATUS = 3 AND LFD.STATUS = 3
AND LFM.STATUS = 3 AND LFM.STATUS = 3
</select> </select>
<select id="selectUserIdxByUserTokenIdx" resultType="java.lang.Integer">
/*FileMapper.selectUserIdxByUserTokenIdx*/
SELECT user_idx
FROM test_user_token
WHERE user_token_idx = #{userTokenIdx}
</select>
<update id="updateFileDetailDeleteByFileUuid"> <update id="updateFileDetailDeleteByFileUuid">
/*FileMapper.updateFileDetailDeleteByFileUuid*/ /*FileMapper.updateFileDetailDeleteByFileUuid*/
UPDATE ALISTLMS.FILE_DETAIL LFD UPDATE ALISTLMS.FILE_DETAIL LFD
INNER JOIN ALISTLMS.FILE_MASTER LFM INNER JOIN ALISTLMS.FILE_MASTER LFM
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /