diff --git a/src/main/java/com/alist/api/common/utils/SecurityUtil.java b/src/main/java/com/alist/api/common/utils/SecurityUtil.java new file mode 100644 index 0000000..34faed4 --- /dev/null +++ b/src/main/java/com/alist/api/common/utils/SecurityUtil.java @@ -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; + } + } +} diff --git a/src/main/java/com/alist/api/common/utils/SessionUtil.java b/src/main/java/com/alist/api/common/utils/SessionUtil.java index 586b098..d54087f 100644 --- a/src/main/java/com/alist/api/common/utils/SessionUtil.java +++ b/src/main/java/com/alist/api/common/utils/SessionUtil.java @@ -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; } } diff --git a/src/main/java/com/alist/api/config/SecurityConfig.java b/src/main/java/com/alist/api/config/SecurityConfig.java index 247f8f2..6d08425 100644 --- a/src/main/java/com/alist/api/config/SecurityConfig.java +++ b/src/main/java/com/alist/api/config/SecurityConfig.java @@ -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); diff --git a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java index 70d16d7..79f0a62 100644 --- a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java @@ -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()); diff --git a/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java index 81157dd..a9a8449 100644 --- a/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java +++ b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java @@ -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(); } diff --git a/src/main/java/com/alist/api/modules/auth/AuthController.java b/src/main/java/com/alist/api/modules/auth/AuthController.java index af77480..fe3787d 100644 --- a/src/main/java/com/alist/api/modules/auth/AuthController.java +++ b/src/main/java/com/alist/api/modules/auth/AuthController.java @@ -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>> 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 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 = "리프레시 토큰으로 엑세스 토큰 재발급" diff --git a/src/main/java/com/alist/api/modules/auth/SsoController.java b/src/main/java/com/alist/api/modules/auth/SsoController.java new file mode 100644 index 0000000..427c79b --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/SsoController.java @@ -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> 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>> loginChecked( + HttpServletRequest request + , HttpServletResponse response + ) { + String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName); + + Map 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 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> 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>> 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); + } +} diff --git a/src/main/java/com/alist/api/modules/auth/dto/SsoExchangeDto.java b/src/main/java/com/alist/api/modules/auth/dto/SsoExchangeDto.java new file mode 100644 index 0000000..f2388f3 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/dto/SsoExchangeDto.java @@ -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; +} diff --git a/src/main/java/com/alist/api/modules/auth/dto/SsoLoginCheckDto.java b/src/main/java/com/alist/api/modules/auth/dto/SsoLoginCheckDto.java new file mode 100644 index 0000000..0e638ea --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/dto/SsoLoginCheckDto.java @@ -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; +} diff --git a/src/main/java/com/alist/api/modules/auth/dto/SsoLoginDto.java b/src/main/java/com/alist/api/modules/auth/dto/SsoLoginDto.java new file mode 100644 index 0000000..5c1506f --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/dto/SsoLoginDto.java @@ -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; + } +} diff --git a/src/main/java/com/alist/api/modules/auth/form/SsoExchangeForm.java b/src/main/java/com/alist/api/modules/auth/form/SsoExchangeForm.java new file mode 100644 index 0000000..5431bd6 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/form/SsoExchangeForm.java @@ -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; + } +} diff --git a/src/main/java/com/alist/api/modules/auth/form/SsoLoginForm.java b/src/main/java/com/alist/api/modules/auth/form/SsoLoginForm.java new file mode 100644 index 0000000..20e8284 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/form/SsoLoginForm.java @@ -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; + } +} diff --git a/src/main/java/com/alist/api/modules/auth/mapper/SsoMapper.java b/src/main/java/com/alist/api/modules/auth/mapper/SsoMapper.java new file mode 100644 index 0000000..84494c8 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/mapper/SsoMapper.java @@ -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); +} diff --git a/src/main/java/com/alist/api/modules/auth/service/SsoService.java b/src/main/java/com/alist/api/modules/auth/service/SsoService.java new file mode 100644 index 0000000..2547444 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/service/SsoService.java @@ -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); + } + +} diff --git a/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java b/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java index 765902c..e13e7d9 100644 --- a/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java +++ b/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java @@ -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); + } } \ No newline at end of file diff --git a/src/main/java/com/alist/api/modules/auth/vo/SsoClientVo.java b/src/main/java/com/alist/api/modules/auth/vo/SsoClientVo.java new file mode 100644 index 0000000..f913137 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/vo/SsoClientVo.java @@ -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; +} diff --git a/src/main/java/com/alist/api/modules/file/FileController.java b/src/main/java/com/alist/api/modules/file/FileController.java index 6337152..ff0f439 100644 --- a/src/main/java/com/alist/api/modules/file/FileController.java +++ b/src/main/java/com/alist/api/modules/file/FileController.java @@ -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> 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> 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); diff --git a/src/main/java/com/alist/api/modules/file/mapper/FileMapper.java b/src/main/java/com/alist/api/modules/file/mapper/FileMapper.java index 0e2a2d3..68eb7a2 100644 --- a/src/main/java/com/alist/api/modules/file/mapper/FileMapper.java +++ b/src/main/java/com/alist/api/modules/file/mapper/FileMapper.java @@ -48,4 +48,6 @@ public interface FileMapper { int updateFileDetailDeleteByFileUuid(FileDeleteDto fileDeleteDto); int updateFileMasterDeleteByFileUuid(FileDeleteDto fileDeleteDto); + + Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx); } diff --git a/src/main/java/com/alist/api/modules/file/service/FileService.java b/src/main/java/com/alist/api/modules/file/service/FileService.java index 404ff7c..86cdea1 100644 --- a/src/main/java/com/alist/api/modules/file/service/FileService.java +++ b/src/main/java/com/alist/api/modules/file/service/FileService.java @@ -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); + } } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 5026f68..3fc3cf6 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -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: @@ -57,4 +57,12 @@ springdoc: api-docs: enabled: true swagger-ui: - enabled: true \ No newline at end of file + enabled: true + +sso: + session: + ttl-seconds: 7200 + code: + ttl-seconds: 300 + cookie: + name: ALIST_SSO \ No newline at end of file diff --git a/src/main/resources/application-pjt.yaml b/src/main/resources/application-pjt.yaml index 0b1f4e7..ea84795 100644 --- a/src/main/resources/application-pjt.yaml +++ b/src/main/resources/application-pjt.yaml @@ -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: @@ -59,4 +59,12 @@ springdoc: api-docs: enabled: true swagger-ui: - enabled: true \ No newline at end of file + enabled: true + +sso: + session: + ttl-seconds: 7200 + code: + ttl-seconds: 300 + cookie: + name: ALIST_SSO \ No newline at end of file diff --git a/src/main/resources/mapper/auth/SsoMapper.xml b/src/main/resources/mapper/auth/SsoMapper.xml new file mode 100644 index 0000000..066e11e --- /dev/null +++ b/src/main/resources/mapper/auth/SsoMapper.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/src/main/resources/mapper/file/FileMapper.xml b/src/main/resources/mapper/file/FileMapper.xml index c7ce617..91b510b 100644 --- a/src/main/resources/mapper/file/FileMapper.xml +++ b/src/main/resources/mapper/file/FileMapper.xml @@ -315,8 +315,14 @@ AND LFD.STATUS = 3 AND LFM.STATUS = 3 + - + /*FileMapper.updateFileDetailDeleteByFileUuid*/ UPDATE ALISTLMS.FILE_DETAIL LFD INNER JOIN ALISTLMS.FILE_MASTER LFM diff --git a/src/main/resources/robots.txt b/src/main/resources/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/src/main/resources/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file