[API] SSO 다중 도메인 로그인 작업 커밋
This commit is contained in:
@@ -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;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
|
||||
public class SessionUtil {
|
||||
// 쿠키 삭제
|
||||
public static void expireCookie(HttpServletResponse response, String name, String cookieDomain, boolean cookieSecure, String cookieSameSite) {
|
||||
ResponseCookie cookie = ResponseCookie.from(name, "")
|
||||
.domain(cookieDomain)
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "")
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(cookieSecure)
|
||||
.sameSite(cookieSameSite)
|
||||
.maxAge(0)
|
||||
.build();
|
||||
.maxAge(0);
|
||||
|
||||
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) {
|
||||
ResponseCookie cookie = ResponseCookie.from(name, value)
|
||||
.domain(cookieDomain)
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value)
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(cookieSecure)
|
||||
.sameSite(cookieSameSite)
|
||||
.maxAge(maxAgeSeconds)
|
||||
.build();
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
.maxAge(maxAgeSeconds);
|
||||
|
||||
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())
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/", "/actuator/health", "/auth/**", "/test/**", "/files/tusHook").permitAll()
|
||||
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/test/**", "/files/tusHook").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@@ -28,7 +28,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||
String userId = jwtTokenProvider.getUserId(token);
|
||||
String userId = jwtTokenProvider.getUserTokenIdx(token);
|
||||
|
||||
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 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 expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||
|
||||
@@ -68,7 +68,7 @@ public class JwtTokenProvider {
|
||||
}
|
||||
|
||||
/** 토큰에서 subject 추출 */
|
||||
public String getUserId(String token) {
|
||||
public String getUserTokenIdx(String token) {
|
||||
return parseClaims(token).getSubject();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.alist.api.modules.auth;
|
||||
|
||||
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.TokenDto;
|
||||
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;
|
||||
@@ -46,10 +48,16 @@ public class AuthController {
|
||||
@Value("${jwt.refresh-token-validity-seconds}")
|
||||
private long refreshTokenValiditySeconds;
|
||||
|
||||
@Value("${sso.cookie.name}")
|
||||
private String ssoCookieName;
|
||||
|
||||
private final SsoService ssoService;
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
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.testAuthService = testAuthService;
|
||||
}
|
||||
@@ -66,6 +74,43 @@ public class AuthController {
|
||||
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(
|
||||
summary = "리프레시 토큰으로 엑세스 토큰 재발급",
|
||||
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 org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@@ -95,7 +96,7 @@ public class TestAuthService {
|
||||
|
||||
int userTokenIdx;
|
||||
try {
|
||||
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken));
|
||||
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
@@ -125,9 +126,19 @@ public class TestAuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken));
|
||||
int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||
testAuthMapper.clearRefreshTokenByUserTokenIdx(userTokenIdx);
|
||||
} 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;
|
||||
|
||||
import com.alist.api.common.utils.SecurityUtil;
|
||||
import com.alist.api.modules.file.dto.*;
|
||||
import com.alist.api.modules.file.form.*;
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
@@ -42,15 +43,18 @@ public class FileController {
|
||||
@PostMapping("/uploadInit")
|
||||
public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit(
|
||||
@Valid @RequestBody FileUploadForm fileUploadForm
|
||||
, HttpServletRequest request
|
||||
) {
|
||||
FileUploadDto fileUploadDto = fileUploadForm.fileUploadDto();
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
Integer userTokenIdx = (session == null) ? null : (Integer) session.getAttribute("userTokenIdx");
|
||||
Integer userIdx = (session == null) ? null : (Integer) session.getAttribute("userIdx");
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -134,10 +138,8 @@ public class FileController {
|
||||
@PostMapping("/uploadCancel")
|
||||
public ResponseEntity<ApiResponse<String>> uploadCancel(
|
||||
@Valid @RequestBody UploadCancelForm uploadCancelForm
|
||||
, HttpServletRequest request
|
||||
) {
|
||||
HttpSession session = request.getSession(false);
|
||||
Integer userTokenIdx = (session == null) ? null : (Integer) session.getAttribute("userTokenIdx");
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (userTokenIdx == null) {
|
||||
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
||||
|
||||
@@ -48,4 +48,6 @@ public interface FileMapper {
|
||||
int updateFileDetailDeleteByFileUuid(FileDeleteDto fileDeleteDto);
|
||||
|
||||
int updateFileMasterDeleteByFileUuid(FileDeleteDto fileDeleteDto);
|
||||
|
||||
Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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.mapper.FileMapper;
|
||||
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
@@ -94,7 +93,7 @@ public class FileService {
|
||||
}
|
||||
|
||||
try {
|
||||
uploadTokenDto.setUserTokenIdx(Integer.parseInt(jwtTokenProvider.getUserId(uploadTokenDto.getUploadToken())));
|
||||
uploadTokenDto.setUserTokenIdx(Integer.parseInt(jwtTokenProvider.getUserTokenIdx(uploadTokenDto.getUploadToken())));
|
||||
} catch (NumberFormatException e) {
|
||||
uploadTokenDto.setResultCode(401);
|
||||
return uploadTokenDto;
|
||||
@@ -499,7 +498,7 @@ public class FileService {
|
||||
}
|
||||
|
||||
private String buildUploadStatusKey(String fileUuid) {
|
||||
return "upload:alist:status:" + fileUuid;
|
||||
return "alist:upload:status:" + fileUuid;
|
||||
}
|
||||
|
||||
private long parseLong(Object value) {
|
||||
@@ -524,7 +523,7 @@ public class FileService {
|
||||
normalizedSize
|
||||
);
|
||||
|
||||
return "upload:alist:auth:" + sha256(raw);
|
||||
return "alist:upload:auth:" + sha256(raw);
|
||||
}
|
||||
|
||||
private String normalize(String v) {
|
||||
@@ -587,24 +586,11 @@ public class FileService {
|
||||
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)
|
||||
public FileDownloadListDto selectFileDownloadListByFileMasterIdx(FileDownloadDto fileDownloadDto) {
|
||||
FileDownloadListDto fileDownloadList = new FileDownloadListDto();
|
||||
|
||||
Integer userTokenIdx = getLoginUserTokenIdx();
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
if (userTokenIdx == null) {
|
||||
fileDownloadList.setResultCode(401);
|
||||
return fileDownloadList;
|
||||
@@ -628,7 +614,7 @@ public class FileService {
|
||||
}
|
||||
|
||||
public FileDownloadDto selectFileViewOrDownload(FileDownloadDto fileDownloadDto) {
|
||||
Integer userTokenIdx = getLoginUserTokenIdx();
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
if (userTokenIdx == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -674,7 +660,7 @@ public class FileService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Integer userTokenIdx = getLoginUserTokenIdx();
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
if (userTokenIdx == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -695,4 +681,12 @@ public class FileService {
|
||||
fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx) {
|
||||
if (userTokenIdx == null) {
|
||||
return null;
|
||||
}
|
||||
return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ jwt:
|
||||
refresh-token-validity-seconds: 2592000
|
||||
|
||||
cookie:
|
||||
secure: false # 로컬 개발 환경 (HTTP)
|
||||
domain:
|
||||
name: ALIST_SESSION
|
||||
same-site: Lax
|
||||
secure: true # 로컬 개발 환경 (HTTP)
|
||||
domain: api-alist.pjt.kr
|
||||
name: ALIST_SSO
|
||||
same-site: None
|
||||
|
||||
swagger:
|
||||
login:
|
||||
@@ -58,3 +58,11 @@ springdoc:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
|
||||
sso:
|
||||
session:
|
||||
ttl-seconds: 7200
|
||||
code:
|
||||
ttl-seconds: 300
|
||||
cookie:
|
||||
name: ALIST_SSO
|
||||
@@ -36,8 +36,8 @@ jwt:
|
||||
|
||||
cookie:
|
||||
secure: true # 프로젝트 환경 (HTTPS)
|
||||
domain: pjt.kr # sso 인증시 주석해제
|
||||
name: ALIST_SESSION
|
||||
domain: api-alist.pjt.kr # sso 인증시 주석해제
|
||||
name: ALIST_SSO
|
||||
same-site: None
|
||||
|
||||
swagger:
|
||||
@@ -60,3 +60,11 @@ springdoc:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
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,6 +315,12 @@
|
||||
AND LFD.STATUS = 3
|
||||
AND LFM.STATUS = 3
|
||||
</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">
|
||||
/*FileMapper.updateFileDetailDeleteByFileUuid*/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
Reference in New Issue
Block a user