diff --git a/build.gradle.kts b/build.gradle.kts index f382796..64bf1c2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,11 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-security") // Spring Security implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") // OAuth 2.0 Resource server // TODO: Spring Security OAuth2 Resource Server 사용에 대해 검토해야 함 + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") // OAuth2 starter + implementation("io.jsonwebtoken:jjwt-api:0.12.3") + implementation("io.jsonwebtoken:jjwt-impl:0.12.3") + implementation("io.jsonwebtoken:jjwt-jackson:0.12.3") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") //타임리프 // Spring Data Redis 추가 // implementation("org.springframework.boot:spring-boot-starter-data-redis") diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/AuthController.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/AuthController.java index 01a79b2..ce17371 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/AuthController.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/AuthController.java @@ -1,336 +1,336 @@ -package com.codezerotoone.mvp.domain.member.auth.controller; - -import com.codezerotoone.mvp.domain.member.auth.controller.schema.RefreshedAccessTokenResponseSchema; -import com.codezerotoone.mvp.domain.member.auth.dto.response.LoginResult; -import com.codezerotoone.mvp.domain.member.auth.dto.response.RefreshedAccessTokenResponseDto; -import com.codezerotoone.mvp.domain.member.auth.service.AuthService; -import com.codezerotoone.mvp.global.api.format.BaseResponse; -import com.codezerotoone.mvp.global.api.schema.LongValueSchema; -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.support.TokenSupport; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import com.codezerotoone.mvp.global.util.http.cookie.HttpCookieName; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.headers.Header; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 로그인, 토큰 리프레시 등 인증/인가와 관련된 엔드포인트 - * - * @author PGD - */ -@RestController -@RequestMapping("/api/v1/auth") -@Tag( - name = "인증 API", - description = "토큰 / 로그인 등 인증/인가와 관련된 API" -) -@Slf4j -public class AuthController { - private static final long REFRESH_TOKEN_COOKIE_MAX_AGE = 360000; - private static final Pattern REDIRECTION_DESTINATION_PATTERN = - Pattern.compile("^(http|https)://[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)*(:\\d{1,5})?$"); - - private final AuthService authService; - private final TokenSupport tokenSupport; - private final String serverOrigin; - private final String clientDomain; - private final String clientOrigin; - - public AuthController(AuthService authService, - TokenSupport tokenSupport, - @Value("${server.origin}") String serverOrigin, - @Value("${client.domain}") String clientDomain, - @Value("${client.origin}") List clientOrigins) { - this.authService = authService; - this.tokenSupport = tokenSupport; - this.serverOrigin = serverOrigin; - this.clientDomain = clientDomain; - this.clientOrigin = clientOrigins.getFirst(); - } - - @Operation( - summary = "OAuth 2.0 소셜 로그인 리다이렉트 URI", - description = "OAuth 2.0 스펙에 따라 로그인을 진행할 때, 리다이렉트되는 엔드포인트. 프론트에서 이 엔드포인트에 직접 요청할 " - + "일은 없고, Auth server (카카오, 구글 등 소셜 로그인 서버)에서 이 엔드포인트로 리다이렉션한다.", - parameters = @Parameter( - name = "authVendor", - description = "OAuth 2.0 Auth server", - in = ParameterIn.PATH, - required = true - ), - responses = { - @ApiResponse( - responseCode = "308", - description = "소셜 로그인 성공. Access Token, Refresh Token, 회원 이름, 프로필 사진 URL 반환", - headers = { - @Header(name = HttpHeaders.SET_COOKIE, - description = "OAuth 2.0 가이드 문서 참조"), - @Header(name = HttpHeaders.LOCATION, - description = """ - 소셜 로그인 후 리다이렉션할 페이지 URL. - [[[ 가입된 회원일 경우: {프론트엔드 도메인}/ ]]], - [[[ 가입되지 않은 사용자일 경우: {프론트엔드 도메인}/sign-up ]]], - [[[ 소셜 로그인이 실패할 경우: {프론트엔드 도메인}/login]]] - """) - } - ) - } - ) - @GetMapping("/{authVendor}/redirect-uri") - public ResponseEntity> oauth2Login(@PathVariable("authVendor") String authVendor, - @RequestParam("code") String code, - // 현재는 state가 Client URL뿐 - @RequestParam(value = "state", required = false) String state, - HttpServletRequest request) - throws UnsupportedCodeException { - - String redirectUri = this.serverOrigin + request.getRequestURI(); - log.info("Redirect URI: {}", redirectUri); - log.debug("code: {}", code); - log.debug("Auth vendor: {}", authVendor); - if (state != null) { - log.debug("state={}", state); - } - - LoginResult loginResult = - this.authService.loginByOAuth2(code, redirectUri, AuthVendor.valueOfIgnoreCase(authVendor)); - - log.debug("loginResult={}", loginResult); - - String redirectionDestination = getRedirectionDestination(state); - log.debug("redirectionDestination={}", redirectionDestination); - - ResponseCookie refreshTokenCookie = generateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), - loginResult.getRefreshToken(), - this.clientDomain, - true); - - log.debug("refreshTokenCookie={}", refreshTokenCookie); - - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - - UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(redirectionDestination) - .path("/redirection") - .queryParam("type", "oauth2") - .queryParam("is-success", true) - .queryParam("access-token", loginResult.getAccessToken()) - .queryParam("is-guest", loginResult.isNewMember()) - .queryParam("auth-vendor", authVendor); - - if (loginResult.isNewMember()) { - // 소셜 로그인 개인정보 동의항목 정보 세팅 - uriComponentsBuilder = addParameterIfNotNull(uriComponentsBuilder, "user-name", loginResult.getUserName()); - uriComponentsBuilder = addParameterIfNotNull(uriComponentsBuilder, "profile-image-url", loginResult.getProfileImageUrl()); - } else { - uriComponentsBuilder = uriComponentsBuilder.queryParam("member-id", loginResult.getMemberId()); - } - - headers.add( - HttpHeaders.LOCATION, - uriComponentsBuilder.encode().toUriString() - ); - - return new ResponseEntity<>(headers, HttpStatus.PERMANENT_REDIRECT); - } - - private ResponseCookie generateCookie(String name, String value, String domain, boolean httpOnly) { - return ResponseCookie.from(name) - .value(value) - .domain(this.clientDomain) - .path("/") - .httpOnly(httpOnly) - .secure(true) - // TODO: Refresh token 유효 시간과 일치시키기 - .maxAge(REFRESH_TOKEN_COOKIE_MAX_AGE) - .sameSite("None") - .build(); - } - - private String getRedirectionDestination(String client) { - String destination = extractDestination(client); - if (destination != null) { - log.debug("From query parameter"); - return destination; - } - - log.debug("Redirect to default origin"); - return this.clientOrigin; - } - - private String extractDestination(String requesterUri) { - if (!StringUtils.hasText(requesterUri)) { - return null; - } - - URI uri = URI.create(requesterUri); - - if (uri.getAuthority() == null) { - return null; - } - - String redirectionTarget = uri.getScheme() + "://" + uri.getAuthority(); - - Matcher matcher = REDIRECTION_DESTINATION_PATTERN.matcher(redirectionTarget); - return matcher.matches() - ? redirectionTarget - : null; - } - - private UriComponentsBuilder addParameterIfNotNull(UriComponentsBuilder builder, String name, String value) { - if (value != null) { - return builder.queryParam(name, value); - } - return builder; - } - - @Operation( - summary = "토큰 리프레시", - description = "Refresh token으로 새 Access token을 발급받는 엔드포인트.", - parameters = @Parameter( - name = "refresh_token", - in = ParameterIn.COOKIE, - required = true, - description = "Refresh Token. Refresh Token은 기본적으로 HTTP-only 쿠키에 담겨 있다." - ), - responses = { - @ApiResponse( - responseCode = "201", - content = @Content( - schema = @Schema(implementation = RefreshedAccessTokenResponseSchema.class), - examples = @ExampleObject(""" - { - "statusCode": 201, - "content": { - "accessToken": "f8310f8asohvh80scvh0zio3hr31d" - } - } - """) - ), - headers = @Header( - name = HttpHeaders.SET_COOKIE, - description = "Refresh Token; HTTP-only" - ) - ), - @ApiResponse( - responseCode = "400", - content = @Content( - examples = @ExampleObject(""" - { - "statusCode": 400, - "timestamp": "2025-06-30T20:46:00.451254", - "errorCode": "AUTH004", - "errorName": "INVALID_REFRESH_TOKEN", - "message": "지원하지 않는 리프레시 토큰입니다." - } - """) - ) - ) - } - ) - @GetMapping("/access-token/refresh") - public ResponseEntity> accessToken( - @CookieValue(value = "refresh_token", required = false) String refreshToken) { - if (!StringUtils.hasText(refreshToken)) { - throw new InvalidRefreshTokenException("refresh_token is null"); - } - - GrantedTokenInfo grantedTokenInfo = this.tokenSupport.refreshToken(refreshToken); - - HttpHeaders headers = new HttpHeaders(); - if (grantedTokenInfo != null && grantedTokenInfo.refreshToken() != null) { - ResponseCookie refreshTokenCookie = generateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), - grantedTokenInfo.refreshToken(), - this.clientDomain, - true); - - headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - } - - return new ResponseEntity<>( - BaseResponse.of(new RefreshedAccessTokenResponseDto(grantedTokenInfo.accessToken()), HttpStatus.CREATED), - headers, - HttpStatus.CREATED - ); - } - - @Operation( - summary = "Who am I?", - description = "Access token으로부터 사용자 정보를 가져와 반환. memberId만 반환한다.", - parameters = @Parameter( - in = ParameterIn.HEADER, - name = "Authorization", - description = "Authorization 헤더에 Bearer Token을 담아서 전송" - ), - responses = { - @ApiResponse( - responseCode = "200", - content = @Content( - schema = @Schema(implementation = LongValueSchema.class), - examples = @ExampleObject(""" - { - "statusCode": 200, - "content": 10000 - } - """) - ) - ) - } - ) - @GetMapping("/me") - public ResponseEntity> whoAmI(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { - Long memberId = Long.valueOf(principal.getName()); - return ResponseEntity.ok(BaseResponse.of(memberId, HttpStatus.OK)); - } - - @Operation( - summary = "로그아웃", - description = "Cookie에 저장된 Refresh token을 제거함으로써 로그아웃 진행. 프론트에서 Access token을 제거할 필요가 " - + "있음" - ) - @PostMapping("/logout") - public ResponseEntity> logout() { - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.MEMBER_ID.getCookieName(), false).toString()); - headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.ACCESS_TOKEN.getCookieName(), false).toString()); - headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), false).toString()); - return new ResponseEntity<>(BaseResponse.of(HttpStatus.OK), headers, HttpStatus.OK); - } - - private ResponseCookie invalidateCookie(String name, boolean httpOnly) { - return ResponseCookie.from(name) - .domain(this.clientDomain) - .path("/") - .httpOnly(httpOnly) - .secure(true) - .maxAge(0) - .sameSite("None") - .build(); - } -} +//package com.codezerotoone.mvp.domain.member.auth.controller; +// +//import com.codezerotoone.mvp.domain.member.auth.controller.schema.RefreshedAccessTokenResponseSchema; +//import com.codezerotoone.mvp.domain.member.auth.dto.response.LoginResult; +//import com.codezerotoone.mvp.domain.member.auth.dto.response.RefreshedAccessTokenResponseDto; +//import com.codezerotoone.mvp.domain.member.auth.service.AuthService; +//import com.codezerotoone.mvp.global.api.format.BaseResponse; +//import com.codezerotoone.mvp.global.api.schema.LongValueSchema; +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.support.TokenSupport; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import com.codezerotoone.mvp.global.util.http.cookie.HttpCookieName; +//import io.swagger.v3.oas.annotations.Operation; +//import io.swagger.v3.oas.annotations.Parameter; +//import io.swagger.v3.oas.annotations.enums.ParameterIn; +//import io.swagger.v3.oas.annotations.headers.Header; +//import io.swagger.v3.oas.annotations.media.Content; +//import io.swagger.v3.oas.annotations.media.ExampleObject; +//import io.swagger.v3.oas.annotations.media.Schema; +//import io.swagger.v3.oas.annotations.responses.ApiResponse; +//import io.swagger.v3.oas.annotations.tags.Tag; +//import jakarta.servlet.http.HttpServletRequest; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.ResponseCookie; +//import org.springframework.http.ResponseEntity; +//import org.springframework.security.core.annotation.AuthenticationPrincipal; +//import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +//import org.springframework.util.StringUtils; +//import org.springframework.web.bind.annotation.*; +//import org.springframework.web.util.UriComponentsBuilder; +// +//import java.net.URI; +//import java.util.List; +//import java.util.regex.Matcher; +//import java.util.regex.Pattern; +// +///** +// * 로그인, 토큰 리프레시 등 인증/인가와 관련된 엔드포인트 +// * +// * @author PGD +// */ +//@RestController +//@RequestMapping("/api/v1/auth") +//@Tag( +// name = "인증 API", +// description = "토큰 / 로그인 등 인증/인가와 관련된 API" +//) +//@Slf4j +//public class AuthController { +// private static final long REFRESH_TOKEN_COOKIE_MAX_AGE = 360000; +// private static final Pattern REDIRECTION_DESTINATION_PATTERN = +// Pattern.compile("^(http|https)://[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)*(:\\d{1,5})?$"); +// +// private final AuthService authService; +// private final TokenSupport tokenSupport; +// private final String serverOrigin; +// private final String clientDomain; +// private final String clientOrigin; +// +// public AuthController(AuthService authService, +// TokenSupport tokenSupport, +// @Value("${server.origin}") String serverOrigin, +// @Value("${client.domain}") String clientDomain, +// @Value("${client.origin}") List clientOrigins) { +// this.authService = authService; +// this.tokenSupport = tokenSupport; +// this.serverOrigin = serverOrigin; +// this.clientDomain = clientDomain; +// this.clientOrigin = clientOrigins.getFirst(); +// } +// +// @Operation( +// summary = "OAuth 2.0 소셜 로그인 리다이렉트 URI", +// description = "OAuth 2.0 스펙에 따라 로그인을 진행할 때, 리다이렉트되는 엔드포인트. 프론트에서 이 엔드포인트에 직접 요청할 " +// + "일은 없고, Auth server (카카오, 구글 등 소셜 로그인 서버)에서 이 엔드포인트로 리다이렉션한다.", +// parameters = @Parameter( +// name = "authVendor", +// description = "OAuth 2.0 Auth server", +// in = ParameterIn.PATH, +// required = true +// ), +// responses = { +// @ApiResponse( +// responseCode = "308", +// description = "소셜 로그인 성공. Access Token, Refresh Token, 회원 이름, 프로필 사진 URL 반환", +// headers = { +// @Header(name = HttpHeaders.SET_COOKIE, +// description = "OAuth 2.0 가이드 문서 참조"), +// @Header(name = HttpHeaders.LOCATION, +// description = """ +// 소셜 로그인 후 리다이렉션할 페이지 URL. +// [[[ 가입된 회원일 경우: {프론트엔드 도메인}/ ]]], +// [[[ 가입되지 않은 사용자일 경우: {프론트엔드 도메인}/sign-up ]]], +// [[[ 소셜 로그인이 실패할 경우: {프론트엔드 도메인}/login]]] +// """) +// } +// ) +// } +// ) +// @GetMapping("/{authVendor}/redirect-uri") +// public ResponseEntity> oauth2Login(@PathVariable("authVendor") String authVendor, +// @RequestParam("code") String code, +// // 현재는 state가 Client URL뿐 +// @RequestParam(value = "state", required = false) String state, +// HttpServletRequest request) +// throws UnsupportedCodeException { +// +// String redirectUri = this.serverOrigin + request.getRequestURI(); +// log.info("Redirect URI: {}", redirectUri); +// log.debug("code: {}", code); +// log.debug("Auth vendor: {}", authVendor); +// if (state != null) { +// log.debug("state={}", state); +// } +// +// LoginResult loginResult = +// this.authService.loginByOAuth2(code, redirectUri, AuthVendor.valueOfIgnoreCase(authVendor)); +// +// log.debug("loginResult={}", loginResult); +// +// String redirectionDestination = getRedirectionDestination(state); +// log.debug("redirectionDestination={}", redirectionDestination); +// +// ResponseCookie refreshTokenCookie = generateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), +// loginResult.getRefreshToken(), +// this.clientDomain, +// true); +// +// log.debug("refreshTokenCookie={}", refreshTokenCookie); +// +// HttpHeaders headers = new HttpHeaders(); +// headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); +// +// UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(redirectionDestination) +// .path("/redirection") +// .queryParam("type", "oauth2") +// .queryParam("is-success", true) +// .queryParam("access-token", loginResult.getAccessToken()) +// .queryParam("is-guest", loginResult.isNewMember()) +// .queryParam("auth-vendor", authVendor); +// +// if (loginResult.isNewMember()) { +// // 소셜 로그인 개인정보 동의항목 정보 세팅 +// uriComponentsBuilder = addParameterIfNotNull(uriComponentsBuilder, "user-name", loginResult.getUserName()); +// uriComponentsBuilder = addParameterIfNotNull(uriComponentsBuilder, "profile-image-url", loginResult.getProfileImageUrl()); +// } else { +// uriComponentsBuilder = uriComponentsBuilder.queryParam("member-id", loginResult.getMemberId()); +// } +// +// headers.add( +// HttpHeaders.LOCATION, +// uriComponentsBuilder.encode().toUriString() +// ); +// +// return new ResponseEntity<>(headers, HttpStatus.PERMANENT_REDIRECT); +// } +// +// private ResponseCookie generateCookie(String name, String value, String domain, boolean httpOnly) { +// return ResponseCookie.from(name) +// .value(value) +// .domain(this.clientDomain) +// .path("/") +// .httpOnly(httpOnly) +// .secure(true) +// // TODO: Refresh token 유효 시간과 일치시키기 +// .maxAge(REFRESH_TOKEN_COOKIE_MAX_AGE) +// .sameSite("None") +// .build(); +// } +// +// private String getRedirectionDestination(String client) { +// String destination = extractDestination(client); +// if (destination != null) { +// log.debug("From query parameter"); +// return destination; +// } +// +// log.debug("Redirect to default origin"); +// return this.clientOrigin; +// } +// +// private String extractDestination(String requesterUri) { +// if (!StringUtils.hasText(requesterUri)) { +// return null; +// } +// +// URI uri = URI.create(requesterUri); +// +// if (uri.getAuthority() == null) { +// return null; +// } +// +// String redirectionTarget = uri.getScheme() + "://" + uri.getAuthority(); +// +// Matcher matcher = REDIRECTION_DESTINATION_PATTERN.matcher(redirectionTarget); +// return matcher.matches() +// ? redirectionTarget +// : null; +// } +// +// private UriComponentsBuilder addParameterIfNotNull(UriComponentsBuilder builder, String name, String value) { +// if (value != null) { +// return builder.queryParam(name, value); +// } +// return builder; +// } +// +// @Operation( +// summary = "토큰 리프레시", +// description = "Refresh token으로 새 Access token을 발급받는 엔드포인트.", +// parameters = @Parameter( +// name = "refresh_token", +// in = ParameterIn.COOKIE, +// required = true, +// description = "Refresh Token. Refresh Token은 기본적으로 HTTP-only 쿠키에 담겨 있다." +// ), +// responses = { +// @ApiResponse( +// responseCode = "201", +// content = @Content( +// schema = @Schema(implementation = RefreshedAccessTokenResponseSchema.class), +// examples = @ExampleObject(""" +// { +// "statusCode": 201, +// "content": { +// "accessToken": "f8310f8asohvh80scvh0zio3hr31d" +// } +// } +// """) +// ), +// headers = @Header( +// name = HttpHeaders.SET_COOKIE, +// description = "Refresh Token; HTTP-only" +// ) +// ), +// @ApiResponse( +// responseCode = "400", +// content = @Content( +// examples = @ExampleObject(""" +// { +// "statusCode": 400, +// "timestamp": "2025-06-30T20:46:00.451254", +// "errorCode": "AUTH004", +// "errorName": "INVALID_REFRESH_TOKEN", +// "message": "지원하지 않는 리프레시 토큰입니다." +// } +// """) +// ) +// ) +// } +// ) +// @GetMapping("/access-token/refresh") +// public ResponseEntity> accessToken( +// @CookieValue(value = "refresh_token", required = false) String refreshToken) { +// if (!StringUtils.hasText(refreshToken)) { +// throw new InvalidRefreshTokenException("refresh_token is null"); +// } +// +// GrantedTokenInfo grantedTokenInfo = this.tokenSupport.refreshToken(refreshToken); +// +// HttpHeaders headers = new HttpHeaders(); +// if (grantedTokenInfo != null && grantedTokenInfo.refreshToken() != null) { +// ResponseCookie refreshTokenCookie = generateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), +// grantedTokenInfo.refreshToken(), +// this.clientDomain, +// true); +// +// headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); +// } +// +// return new ResponseEntity<>( +// BaseResponse.of(new RefreshedAccessTokenResponseDto(grantedTokenInfo.accessToken()), HttpStatus.CREATED), +// headers, +// HttpStatus.CREATED +// ); +// } +// +// @Operation( +// summary = "Who am I?", +// description = "Access token으로부터 사용자 정보를 가져와 반환. memberId만 반환한다.", +// parameters = @Parameter( +// in = ParameterIn.HEADER, +// name = "Authorization", +// description = "Authorization 헤더에 Bearer Token을 담아서 전송" +// ), +// responses = { +// @ApiResponse( +// responseCode = "200", +// content = @Content( +// schema = @Schema(implementation = LongValueSchema.class), +// examples = @ExampleObject(""" +// { +// "statusCode": 200, +// "content": 10000 +// } +// """) +// ) +// ) +// } +// ) +// @GetMapping("/me") +// public ResponseEntity> whoAmI(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { +// Long memberId = Long.valueOf(principal.getName()); +// return ResponseEntity.ok(BaseResponse.of(memberId, HttpStatus.OK)); +// } +// +// @Operation( +// summary = "로그아웃", +// description = "Cookie에 저장된 Refresh token을 제거함으로써 로그아웃 진행. 프론트에서 Access token을 제거할 필요가 " +// + "있음" +// ) +// @PostMapping("/logout") +// public ResponseEntity> logout() { +// HttpHeaders headers = new HttpHeaders(); +// headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.MEMBER_ID.getCookieName(), false).toString()); +// headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.ACCESS_TOKEN.getCookieName(), false).toString()); +// headers.add(HttpHeaders.SET_COOKIE, invalidateCookie(HttpCookieName.REFRESH_TOKEN.getCookieName(), false).toString()); +// return new ResponseEntity<>(BaseResponse.of(HttpStatus.OK), headers, HttpStatus.OK); +// } +// +// private ResponseCookie invalidateCookie(String name, boolean httpOnly) { +// return ResponseCookie.from(name) +// .domain(this.clientDomain) +// .path("/") +// .httpOnly(httpOnly) +// .secure(true) +// .maxAge(0) +// .sameSite("None") +// .build(); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/OAuth2PageTestController.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/OAuth2PageTestController.java new file mode 100644 index 0000000..697583a --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/OAuth2PageTestController.java @@ -0,0 +1,12 @@ +package com.codezerotoone.mvp.domain.member.auth.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class OAuth2PageTestController { + @GetMapping("/custom/login") + public String oAuth2LoginPage() { + return "login"; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/errorhandler/AuthErrorHandlingControllerAdvice.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/errorhandler/AuthErrorHandlingControllerAdvice.java index 9fda83e..d0335c2 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/errorhandler/AuthErrorHandlingControllerAdvice.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/controller/errorhandler/AuthErrorHandlingControllerAdvice.java @@ -1,59 +1,59 @@ -package com.codezerotoone.mvp.domain.member.auth.controller.errorhandler; - -import com.codezerotoone.mvp.domain.member.auth.controller.AuthController; -import com.codezerotoone.mvp.global.api.format.ErrorResponse; -import com.codezerotoone.mvp.global.security.exception.errorcode.SecurityErrorCode; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.util.UriComponentsBuilder; - -@RestControllerAdvice(assignableTypes = AuthController.class) -@Slf4j -public class AuthErrorHandlingControllerAdvice { - private final String clientDomain; - private final String clientOrigin; - - public AuthErrorHandlingControllerAdvice(@Value("${client.domain}") String clientDomain, - @Value("${client.origin}") String clientOrigin) { - this.clientDomain = clientDomain; - this.clientOrigin = clientOrigin; - } - - @ExceptionHandler(UnsupportedCodeException.class) - public ResponseEntity unsupportedCodeException(UnsupportedCodeException ex) { - log.info("{}", ex.getMessage()); -// return new ResponseEntity<>(ErrorResponse.of(SecurityErrorCode.UNSUPPORTED_CODE), HttpStatus.UNAUTHORIZED); - - HttpHeaders headers = new HttpHeaders(); - - String redirectionTo = UriComponentsBuilder.fromUriString(this.clientOrigin) - .path("/redirection") - .queryParam("type", "oauth2") - .queryParam("is-success", false) - .build() - .encode() - .toUriString(); - - headers.add(HttpHeaders.LOCATION, redirectionTo); - return new ResponseEntity<>(headers, HttpStatus.PERMANENT_REDIRECT); - } - - @ExceptionHandler(InvalidRefreshTokenException.class) - public ResponseEntity invalidRefreshTokenException(InvalidRefreshTokenException ex) { - log.info("{}", ex.getMessage()); - return ResponseEntity.badRequest() - .body( - ErrorResponse.of( - SecurityErrorCode.INVALID_REFRESH_TOKEN, - ex.getMessage() - ) - ); - } -} +//package com.codezerotoone.mvp.domain.member.auth.controller.errorhandler; +// +//import com.codezerotoone.mvp.domain.member.auth.controller.AuthController; +//import com.codezerotoone.mvp.global.api.format.ErrorResponse; +//import com.codezerotoone.mvp.global.security.exception.errorcode.SecurityErrorCode; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.ExceptionHandler; +//import org.springframework.web.bind.annotation.RestControllerAdvice; +//import org.springframework.web.util.UriComponentsBuilder; +// +//@RestControllerAdvice(assignableTypes = AuthController.class) +//@Slf4j +//public class AuthErrorHandlingControllerAdvice { +// private final String clientDomain; +// private final String clientOrigin; +// +// public AuthErrorHandlingControllerAdvice(@Value("${client.domain}") String clientDomain, +// @Value("${client.origin}") String clientOrigin) { +// this.clientDomain = clientDomain; +// this.clientOrigin = clientOrigin; +// } +// +// @ExceptionHandler(UnsupportedCodeException.class) +// public ResponseEntity unsupportedCodeException(UnsupportedCodeException ex) { +// log.info("{}", ex.getMessage()); +//// return new ResponseEntity<>(ErrorResponse.of(SecurityErrorCode.UNSUPPORTED_CODE), HttpStatus.UNAUTHORIZED); +// +// HttpHeaders headers = new HttpHeaders(); +// +// String redirectionTo = UriComponentsBuilder.fromUriString(this.clientOrigin) +// .path("/redirection") +// .queryParam("type", "oauth2") +// .queryParam("is-success", false) +// .build() +// .encode() +// .toUriString(); +// +// headers.add(HttpHeaders.LOCATION, redirectionTo); +// return new ResponseEntity<>(headers, HttpStatus.PERMANENT_REDIRECT); +// } +// +// @ExceptionHandler(InvalidRefreshTokenException.class) +// public ResponseEntity invalidRefreshTokenException(InvalidRefreshTokenException ex) { +// log.info("{}", ex.getMessage()); +// return ResponseEntity.badRequest() +// .body( +// ErrorResponse.of( +// SecurityErrorCode.INVALID_REFRESH_TOKEN, +// ex.getMessage() +// ) +// ); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/dto/CustomOAuth2User.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/dto/CustomOAuth2User.java new file mode 100644 index 0000000..56907ab --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/dto/CustomOAuth2User.java @@ -0,0 +1,52 @@ +package com.codezerotoone.mvp.domain.member.auth.dto; + +import com.codezerotoone.mvp.domain.member.member.dto.MemberDto; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + + private final MemberDto memberDto; + + @Override + public A getAttribute(String name) { + return OAuth2User.super.getAttribute(name); + } + + @Override + public String getName() { + return memberDto.getOidcId(); + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return memberDto.getRole(); + } + }); + + return collection; + } + + public Long getMemberId() { + return memberDto.getMemberId(); + } + + public String getRole() { + return memberDto.getRole(); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java index 3e97406..a3f8131 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java @@ -1,48 +1,48 @@ -package com.codezerotoone.mvp.domain.member.auth.service; - -import com.codezerotoone.mvp.domain.member.auth.dto.response.LoginResult; -import com.codezerotoone.mvp.domain.member.member.entity.Member; -import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.support.TokenSupport; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AuthService { - private final TokenSupport tokenSupport; - private final MemberRepository memberRepository; - - @Transactional - public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor authVendor) - throws UnsupportedCodeException { - GrantedTokenInfo grantedTokenInfo = this.tokenSupport.grantToken(code, redirectUri, authVendor); - Optional memberOp = this.memberRepository.findByOdicId(grantedTokenInfo.id()); - if (memberOp.isPresent()) { - Member member = memberOp.get(); - return LoginResult.builder() - .newMember(false) - .accessToken(grantedTokenInfo.accessToken()) - .refreshToken(grantedTokenInfo.refreshToken()) - .memberId(member.getMemberId()) - .build(); - } - OAuth2UserInfo userInfo = this.tokenSupport.retrieveUserInfo(grantedTokenInfo.accessToken()); - return LoginResult.builder() - .newMember(true) - .accessToken(grantedTokenInfo.accessToken()) - .refreshToken(grantedTokenInfo.refreshToken()) - .profileImageUrl(userInfo.profileImageUrl()) - .userName(userInfo.name()) - .build(); - } -} +//package com.codezerotoone.mvp.domain.member.auth.service; +// +//import com.codezerotoone.mvp.domain.member.auth.dto.response.LoginResult; +//import com.codezerotoone.mvp.domain.member.member.entity.Member; +//import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.support.TokenSupport; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.stereotype.Service; +//import org.springframework.transaction.annotation.Transactional; +// +//import java.util.Optional; +// +//@Service +//@RequiredArgsConstructor +//@Slf4j +//public class AuthService { +// private final TokenSupport tokenSupport; +// private final MemberRepository memberRepository; +// +// @Transactional +// public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor authVendor) +// throws UnsupportedCodeException { +// GrantedTokenInfo grantedTokenInfo = this.tokenSupport.grantToken(code, redirectUri, authVendor); +// Optional memberOp = this.memberRepository.findByOdicId(grantedTokenInfo.id()); +// if (memberOp.isPresent()) { +// Member member = memberOp.get(); +// return LoginResult.builder() +// .newMember(false) +// .accessToken(grantedTokenInfo.accessToken()) +// .refreshToken(grantedTokenInfo.refreshToken()) +// .memberId(member.getMemberId()) +// .build(); +// } +// OAuth2UserInfo userInfo = this.tokenSupport.retrieveUserInfo(grantedTokenInfo.accessToken()); +// return LoginResult.builder() +// .newMember(true) +// .accessToken(grantedTokenInfo.accessToken()) +// .refreshToken(grantedTokenInfo.refreshToken()) +// .profileImageUrl(userInfo.profileImageUrl()) +// .userName(userInfo.name()) +// .build(); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAccessDeniedHandler.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..59c25c2 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAccessDeniedHandler.java @@ -0,0 +1,19 @@ +package com.codezerotoone.mvp.domain.member.auth.service; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAuthenticationEntryPoint.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..252ebf9 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomAuthenticationEntryPoint.java @@ -0,0 +1,25 @@ +package com.codezerotoone.mvp.domain.member.auth.service; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + if (request.getRequestURI().equals("/login")) { + response.sendRedirect("/login"); + return; + } + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomOAuth2UserService.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..8422492 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,52 @@ +package com.codezerotoone.mvp.domain.member.auth.service; + +import com.codezerotoone.mvp.domain.member.auth.dto.CustomOAuth2User; +import com.codezerotoone.mvp.domain.member.auth.entity.Role; +import com.codezerotoone.mvp.domain.member.member.dto.MemberDto; +import com.codezerotoone.mvp.domain.member.member.entity.Member; +import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +import com.codezerotoone.mvp.global.security.token.support.OAuth2Response; +import com.codezerotoone.mvp.global.security.token.vendor.AuthSocial; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final MemberRepository memberRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + log.info("oAuth2User: " + oAuth2User); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + AuthSocial social = AuthSocial.fromName(registrationId); + OAuth2Response oAuth2Response = social.createResponse(oAuth2User.getAttributes()); + String oidcId = oAuth2Response.getProviderId(); + + Member existData = memberRepository.findByOdicId(oidcId).orElse(null); + + if (existData != null) { + MemberDto userDto = MemberDto.fromEntity(existData); + + return new CustomOAuth2User(userDto); + } else { + Member member = Member.createGeneralMemberBySocialLogin("프로필이름", oidcId); + memberRepository.save(member); + + MemberDto memberDto = MemberDto.fromEntity(member); + return new CustomOAuth2User(memberDto); + } + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomSuccessHandler.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomSuccessHandler.java new file mode 100644 index 0000000..4cc999f --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/CustomSuccessHandler.java @@ -0,0 +1,48 @@ +package com.codezerotoone.mvp.domain.member.auth.service; + +import com.codezerotoone.mvp.domain.member.auth.dto.CustomOAuth2User; +import com.codezerotoone.mvp.global.util.CookieUtil; +import com.codezerotoone.mvp.global.util.JwtUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); + + Long memberId = customUserDetails.getMemberId(); + String role = customUserDetails.getAuthorities().iterator().next().getAuthority(); + + String accessToken = jwtUtil.createAccessToken(memberId, role); + String refreshToken = jwtUtil.createRefreshToken(memberId, role); + + response.addHeader("Set-Cookie", cookieUtil.createCookie("Refresh-Token", refreshToken)); + response.addHeader("Set-Cookie", cookieUtil.createCookie("Authorization", accessToken)); + + log.info("Access Token: {}", accessToken); + log.info("Refresh Token: {}", refreshToken); + + String redirectUrl = "http://localhost:3000"; //Role에 따라 추가적인 회원가입 페이지로 리다이렉트 가능 + response.sendRedirect(redirectUrl); + } + + +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/MemberController.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/MemberController.java index 8ea9527..bb43f4f 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/MemberController.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/MemberController.java @@ -1,8 +1,12 @@ package com.codezerotoone.mvp.domain.member.member.controller; +import com.codezerotoone.mvp.domain.member.member.constant.MemberStatus; import com.codezerotoone.mvp.domain.member.member.controller.apidocs.MemberDeletionApiDocs; +import com.codezerotoone.mvp.domain.member.member.controller.apidocs.MemberListApiDocs; import com.codezerotoone.mvp.domain.member.member.controller.apidocs.SignUpApiDocs; +import com.codezerotoone.mvp.domain.member.member.controller.apidocs.UpdateMemberStatusApiDocs; import com.codezerotoone.mvp.domain.member.member.dto.MemberCreationResponseDto; +import com.codezerotoone.mvp.domain.member.member.dto.MemberListDto; import com.codezerotoone.mvp.domain.member.member.dto.request.MemberCreationRequestDto; import com.codezerotoone.mvp.domain.member.member.service.MemberService; import com.codezerotoone.mvp.global.api.format.BaseResponse; @@ -10,6 +14,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -43,7 +49,23 @@ public ResponseEntity> signUp(@Valid @Re @DeleteMapping("/{memberId}") @MemberDeletionApiDocs public ResponseEntity> deleteMember(@PathVariable("memberId") Long memberId) { - this.memberService.deleteMember(memberId); + memberService.deleteMember(memberId); return ResponseEntity.ok(BaseResponse.of(HttpStatus.OK)); } + + @PatchMapping("/{memberId}/status") + @UpdateMemberStatusApiDocs + public ResponseEntity> updateMemberStatus(@PathVariable Long memberId, @RequestParam("status") + MemberStatus status) { + memberService.updateStatus(memberId, status); + return ResponseEntity.ok().build(); + } + + @GetMapping + @MemberListApiDocs + public ResponseEntity>> listMembers(Pageable pageable) { + Page page = memberService.listMember(pageable); + + return ResponseEntity.ok(BaseResponse.of(page, HttpStatus.OK)); + } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/MemberListApiDocs.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/MemberListApiDocs.java new file mode 100644 index 0000000..3060cac --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/MemberListApiDocs.java @@ -0,0 +1,56 @@ +package com.codezerotoone.mvp.domain.member.member.controller.apidocs; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Operation( + summary = "[관리자] 회원 목록 조회", + description = "탈퇴되지 않은 회원을 페이지 단위로 조회합니다.", + parameters = { + @Parameter( + in = ParameterIn.QUERY, + name = "page", + description = "페이지 번호 (0부터 시작)", + schema = @Schema(type = "integer", defaultValue = "0") + ), + @Parameter( + in = ParameterIn.QUERY, + name = "size", + description = "페이지 크기", + schema = @Schema(type = "integer", defaultValue = "20") + ), + @Parameter( + in = ParameterIn.QUERY, + name = "sort", + description = "정렬 기준 (예: createdAt,desc)", + schema = @Schema(type = "string") + ) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "회원 목록 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema( + implementation = com.codezerotoone.mvp.domain.member.member.dto.MemberListDto.class + ) + ) + ), + @ApiResponse(responseCode = "403", description = "권한이 없는 경우") + } +) +public @interface MemberListApiDocs {} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/UpdateMemberStatusApiDocs.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/UpdateMemberStatusApiDocs.java new file mode 100644 index 0000000..66b3abd --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/controller/apidocs/UpdateMemberStatusApiDocs.java @@ -0,0 +1,68 @@ +package com.codezerotoone.mvp.domain.member.member.controller.apidocs; + +import com.codezerotoone.mvp.domain.member.member.dto.request.MemberCreationRequestDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Operation( + summary = "회원 상태 변경", + description = "관리자가 특정 회원의 상태를 ACTIVE/DISABLED/QUIT로 변경", + parameters = { + @Parameter( + name = "memberId", + in = ParameterIn.PATH, + description = "상태를 변경할 회원의 ID", + required = true + ), + @Parameter( + name = "status", + in = ParameterIn.QUERY, + description = "변경할 상태 (ACTIVE, DISABLED, QUIT)", + required = true + ) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "회원 상태 변경 성공", + content = @Content(examples = @ExampleObject(""" + { + "statusCode": 200, + "timestamp": "2025-03-30T12:12:30.013", + "content": null, + "message": null + } + """)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 회원", + content = @Content( + examples = @ExampleObject(""" + { + "statusCode": 404, + "errorName": "MEMBER_NOT_FOUND", + "errorCode": "MEM002", + "detail": 1000, + "timestamp": "2025-03-30T12:12:30.013" + } + """) + ) + ) + } +) +public @interface UpdateMemberStatusApiDocs { +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberDto.java new file mode 100644 index 0000000..a4f6b3e --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberDto.java @@ -0,0 +1,28 @@ +package com.codezerotoone.mvp.domain.member.member.dto; + +import com.codezerotoone.mvp.domain.member.auth.entity.Role; +import com.codezerotoone.mvp.domain.member.member.constant.MemberStatus; +import com.codezerotoone.mvp.domain.member.member.entity.Member; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MemberDto { + + private Long memberId; + private String oidcId; + private String loginId; + private MemberStatus memberStatus; + private String role; + + public static MemberDto fromEntity(Member member) { + return MemberDto.builder() + .memberId(member.getMemberId()) + .oidcId(member.getOidcId()) + .loginId(member.getLoginId()) + .memberStatus(member.getMemberStatus()) + .role(member.getRole().getRoleId()) + .build(); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberListDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberListDto.java new file mode 100644 index 0000000..ef60471 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberListDto.java @@ -0,0 +1,14 @@ +package com.codezerotoone.mvp.domain.member.member.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record MemberListDto( + Long memberId, + String memberName, + LocalDateTime createdAt, + String tel, + String birthDate, + String preferredSubject +) { } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java index 9f3ecca..ca9f6e6 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java @@ -95,11 +95,18 @@ public static Member getReference(Long memberId) { return new Member(memberId); } - public void delete() { - this.deletedAt = LocalDateTime.now(); + public void deleteUser() { + if (this.deletedAt == null) { + this.memberStatus = MemberStatus.QUIT; + this.deletedAt = LocalDateTime.now(); + } } public boolean isDeleted() { return this.deletedAt != null; } + + public void updateStatus(MemberStatus newStatus) { + this.memberStatus = newStatus; + } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java index 0236b3d..51f8ca7 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java @@ -2,6 +2,8 @@ import com.codezerotoone.mvp.domain.member.member.entity.Member; import com.codezerotoone.mvp.domain.member.member.repository.extend.ExtendedMemberRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -42,4 +44,6 @@ SELECT COUNT(*) > 0 AND m.oidcId = :oidcId """) Optional findByOdicId(@Param("oidcId") String oidcId); + + Page findAllByDeletedAtIsNull(Pageable pageable); } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java index 18b74e7..1d95727 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java @@ -1,15 +1,23 @@ package com.codezerotoone.mvp.domain.member.member.service; import com.codezerotoone.mvp.domain.image.constant.ImageExtension; +import com.codezerotoone.mvp.domain.member.member.constant.MemberStatus; import com.codezerotoone.mvp.domain.member.member.dto.MemberCreationResponseDto; +import com.codezerotoone.mvp.domain.member.member.dto.MemberListDto; import com.codezerotoone.mvp.domain.member.member.dto.request.MemberCreationRequestDto; import com.codezerotoone.mvp.domain.member.member.entity.Member; import com.codezerotoone.mvp.domain.member.member.exception.DuplicateMemberException; import com.codezerotoone.mvp.domain.member.member.exception.MemberNotFoundException; import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberInfo; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberProfile; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberProfileData; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.StudySubject; import com.codezerotoone.mvp.global.file.url.FileUrlResolver; import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -52,6 +60,40 @@ public MemberCreationResponseDto createMember(MemberCreationRequestDto request, @Override public void deleteMember(Long memberId) throws MemberNotFoundException { - throw new UnsupportedOperationException(); + Member member = memberRepository.findNotDeletedMemberById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + member.deleteUser(); + } + + @Override + public void updateStatus(Long memberId, MemberStatus status) { + Member member = memberRepository.findNotDeletedMemberById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + member.updateStatus(status); + } + + @Override + public Page listMember(Pageable pageable) { + return memberRepository.findAllByDeletedAtIsNull(pageable) + .map(this::memberToListDto); + } + + private MemberListDto memberToListDto(Member m) { + MemberProfile memberProfile = m.getMemberProfile(); + MemberProfileData memberProfileData = memberProfile.getMemberProfileData(); + MemberInfo memberInfo = memberProfile.getMemberInfo(); + + StudySubject preferredStudySubject = memberInfo.getPreferredStudySubject(); + + return new MemberListDto( + m.getMemberId(), + memberProfile.getMemberName(), + m.getCreatedAt(), + memberProfileData.getTel(), + memberProfileData.getBirthDate().toString(), + preferredStudySubject.getStudySubjectName() + ); } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/MemberService.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/MemberService.java index ac826e0..da0a9bc 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/MemberService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/MemberService.java @@ -1,8 +1,12 @@ package com.codezerotoone.mvp.domain.member.member.service; +import com.codezerotoone.mvp.domain.member.member.constant.MemberStatus; import com.codezerotoone.mvp.domain.member.member.dto.MemberCreationResponseDto; +import com.codezerotoone.mvp.domain.member.member.dto.MemberListDto; import com.codezerotoone.mvp.domain.member.member.dto.request.MemberCreationRequestDto; import com.codezerotoone.mvp.domain.member.member.exception.MemberNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface MemberService { @@ -15,4 +19,7 @@ public interface MemberService { * @throws MemberNotFoundException 해당 회원이 없을 경우. */ void deleteMember(Long memberId) throws MemberNotFoundException; + + void updateStatus(Long memberId, MemberStatus status); + Page listMember(Pageable pageable); } diff --git a/src/main/java/com/codezerotoone/mvp/global/config/security/ApiSecurityFilterChainConfig.java b/src/main/java/com/codezerotoone/mvp/global/config/security/ApiSecurityFilterChainConfig.java index 92dcd3d..fcb4858 100644 --- a/src/main/java/com/codezerotoone/mvp/global/config/security/ApiSecurityFilterChainConfig.java +++ b/src/main/java/com/codezerotoone/mvp/global/config/security/ApiSecurityFilterChainConfig.java @@ -1,171 +1,171 @@ -package com.codezerotoone.mvp.global.config.security; - -import com.codezerotoone.mvp.domain.member.auth.constant.AuthorizedHttpMethod; -import com.codezerotoone.mvp.domain.member.auth.dto.RoleDto; -import com.codezerotoone.mvp.domain.member.auth.dto.response.AllowedEndpointForRole; -import com.codezerotoone.mvp.domain.member.auth.service.RoleService; -import com.codezerotoone.mvp.global.security.filter.AuthenticatedRequestCheckFilter; -import com.codezerotoone.mvp.global.util.methodoverride.HttpMethodOverrideConstant; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; -import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.time.Duration; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Map; - -/** - * A configuration class for REST API endpoints (/api/v1) - * - * @author PGD - */ -@Slf4j -@EnableWebSecurity -@Configuration -public class ApiSecurityFilterChainConfig { - - @Value("${client.cors.allowed-origins}") - private List accessControlAllowedOrigins; - - @Autowired - private AuthenticationEntryPoint authenticationEntryPoint; - - @Autowired - private AccessDeniedHandler accessDeniedHandler; - - @Autowired - private OpaqueTokenIntrospector tokenIntrospector; - - @Autowired - private BearerTokenResolver bearerTokenResolver; - - @Autowired - private RoleService roleService; - - /** - *

Allow every request for the sake of only development convenience. - *

This SecurityFilterChain shouldn't be registered in production environment. - * - * @param http Security configuration - * @return SecurityFilterChain instance - * @throws Exception exception - */ - @Bean - @Profile("no-auth") - @Order(Integer.MIN_VALUE) - public SecurityFilterChain noAuthFilterChain(HttpSecurity http) throws Exception { - return commonSecurityConfig(http) - .authorizeHttpRequests((request) -> request.anyRequest().permitAll()) - .build(); - } - - /** - *

Default Security filter chain for /api/v1 - * - * @param http Security configuration - * @return SecurityFilterChain instance - * @throws Exception exception - */ - @Bean - @Profile("!no-auth") - @Order(Integer.MIN_VALUE) - public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { - return commonSecurityConfig(http) - .authorizeHttpRequests((request) -> { - // TODO: 애플리케이션 실행 중 동적으로 권한을 정의하기 위한 수단이 필요함 - Map> allAccessPermission = - this.roleService.getAllAccessPermission(); - - log.info("allAccessPermission={}", allAccessPermission); - - allAccessPermission.forEach((httpMethod, allowedEndpointForRoles) -> { - for (AllowedEndpointForRole allowedEndpointForRole : allowedEndpointForRoles) { - log.info("httpMethod={}, allowedEndpointForRole={}", httpMethod, allowedEndpointForRole); - request.requestMatchers(HttpMethod.valueOf(httpMethod.name()), allowedEndpointForRole.endpoint()) - .hasAnyRole(allowedEndpointForRole.roles() - .stream() - .map(RoleDto::getCode) - .toArray(String[]::new)); - } - }); - - request.anyRequest().permitAll(); - }) - .build(); - } - - private HttpSecurity commonSecurityConfig(HttpSecurity http) throws Exception { - AuthenticatedRequestCheckFilter authenticatedRequestFilter = - new AuthenticatedRequestCheckFilter(this.roleService, this.authenticationEntryPoint); - authenticatedRequestFilter.initFilterBean(); - return http.securityMatcher("/api/v1/**") - .csrf(AbstractHttpConfigurer::disable) // TODO: consider applying CSRF protection - .cors((configurer) -> configurer.configurationSource(corsConfigurationSource())) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .addFilterAfter(authenticatedRequestFilter, - BearerTokenAuthenticationFilter.class) - .exceptionHandling((config) -> - config.authenticationEntryPoint(this.authenticationEntryPoint) - .accessDeniedHandler(this.accessDeniedHandler)) - .sessionManagement((session) -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .oauth2ResourceServer((resourceServer) -> - resourceServer - .authenticationEntryPoint(this.authenticationEntryPoint) - .bearerTokenResolver(this.bearerTokenResolver) - .opaqueToken((config) -> config.introspector(this.tokenIntrospector))); - } - - private CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.setAllowedOrigins(this.accessControlAllowedOrigins); - corsConfiguration.setAllowedMethods(List.of( - HttpMethod.GET.name(), - HttpMethod.POST.name(), - HttpMethod.PUT.name(), - HttpMethod.DELETE.name(), - HttpMethod.PATCH.name(), - HttpMethod.HEAD.name() - )); - corsConfiguration.setAllowedHeaders(List.of( - HttpHeaders.AUTHORIZATION, - HttpHeaders.COOKIE, - HttpHeaders.UPGRADE, - HttpHeaders.CONTENT_TYPE, - HttpHeaders.ACCEPT, - HttpMethodOverrideConstant.HEADER_NAME - )); - corsConfiguration.setAllowCredentials(true); - corsConfiguration.setExposedHeaders(List.of( - HttpHeaders.UPGRADE, - HttpHeaders.CONTENT_TYPE, - HttpHeaders.SET_COOKIE - )); // TODO: specify response headers - corsConfiguration.setMaxAge(Duration.of(1L, ChronoUnit.HOURS)); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", corsConfiguration); - return source; - } -} +//package com.codezerotoone.mvp.global.config.security; +// +//import com.codezerotoone.mvp.domain.member.auth.constant.AuthorizedHttpMethod; +//import com.codezerotoone.mvp.domain.member.auth.dto.RoleDto; +//import com.codezerotoone.mvp.domain.member.auth.dto.response.AllowedEndpointForRole; +//import com.codezerotoone.mvp.domain.member.auth.service.RoleService; +//import com.codezerotoone.mvp.global.security.filter.AuthenticatedRequestCheckFilter; +//import com.codezerotoone.mvp.global.util.methodoverride.HttpMethodOverrideConstant; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.context.annotation.Profile; +//import org.springframework.core.annotation.Order; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpMethod; +//import org.springframework.security.config.annotation.web.builders.HttpSecurity; +//import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +//import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +//import org.springframework.security.config.http.SessionCreationPolicy; +//import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +//import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +//import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +//import org.springframework.security.web.AuthenticationEntryPoint; +//import org.springframework.security.web.SecurityFilterChain; +//import org.springframework.security.web.access.AccessDeniedHandler; +//import org.springframework.web.cors.CorsConfiguration; +//import org.springframework.web.cors.CorsConfigurationSource; +//import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +// +//import java.time.Duration; +//import java.time.temporal.ChronoUnit; +//import java.util.List; +//import java.util.Map; +// +///** +// * A configuration class for REST API endpoints (/api/v1) +// * +// * @author PGD +// */ +//@Slf4j +//@EnableWebSecurity +//@Configuration +//public class ApiSecurityFilterChainConfig { +// +// @Value("${client.cors.allowed-origins}") +// private List accessControlAllowedOrigins; +// +// @Autowired +// private AuthenticationEntryPoint authenticationEntryPoint; +// +// @Autowired +// private AccessDeniedHandler accessDeniedHandler; +// +// @Autowired +// private OpaqueTokenIntrospector tokenIntrospector; +// +// @Autowired +// private BearerTokenResolver bearerTokenResolver; +// +// @Autowired +// private RoleService roleService; +// +// /** +// *

Allow every request for the sake of only development convenience. +// *

This SecurityFilterChain shouldn't be registered in production environment. +// * +// * @param http Security configuration +// * @return SecurityFilterChain instance +// * @throws Exception exception +// */ +// @Bean +// @Profile("no-auth") +// @Order(Integer.MIN_VALUE) +// public SecurityFilterChain noAuthFilterChain(HttpSecurity http) throws Exception { +// return commonSecurityConfig(http) +// .authorizeHttpRequests((request) -> request.anyRequest().permitAll()) +// .build(); +// } +// +// /** +// *

Default Security filter chain for /api/v1 +// * +// * @param http Security configuration +// * @return SecurityFilterChain instance +// * @throws Exception exception +// */ +// @Bean +// @Profile("!no-auth") +// @Order(Integer.MIN_VALUE) +// public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { +// return commonSecurityConfig(http) +// .authorizeHttpRequests((request) -> { +// // TODO: 애플리케이션 실행 중 동적으로 권한을 정의하기 위한 수단이 필요함 +// Map> allAccessPermission = +// this.roleService.getAllAccessPermission(); +// +// log.info("allAccessPermission={}", allAccessPermission); +// +// allAccessPermission.forEach((httpMethod, allowedEndpointForRoles) -> { +// for (AllowedEndpointForRole allowedEndpointForRole : allowedEndpointForRoles) { +// log.info("httpMethod={}, allowedEndpointForRole={}", httpMethod, allowedEndpointForRole); +// request.requestMatchers(HttpMethod.valueOf(httpMethod.name()), allowedEndpointForRole.endpoint()) +// .hasAnyRole(allowedEndpointForRole.roles() +// .stream() +// .map(RoleDto::getCode) +// .toArray(String[]::new)); +// } +// }); +// +// request.anyRequest().permitAll(); +// }) +// .build(); +// } +// +// private HttpSecurity commonSecurityConfig(HttpSecurity http) throws Exception { +// AuthenticatedRequestCheckFilter authenticatedRequestFilter = +// new AuthenticatedRequestCheckFilter(this.roleService, this.authenticationEntryPoint); +// authenticatedRequestFilter.initFilterBean(); +// return http.securityMatcher("/api/v1/**") +// .csrf(AbstractHttpConfigurer::disable) // TODO: consider applying CSRF protection +// .cors((configurer) -> configurer.configurationSource(corsConfigurationSource())) +// .formLogin(AbstractHttpConfigurer::disable) +// .httpBasic(AbstractHttpConfigurer::disable) +// .addFilterAfter(authenticatedRequestFilter, +// BearerTokenAuthenticationFilter.class) +// .exceptionHandling((config) -> +// config.authenticationEntryPoint(this.authenticationEntryPoint) +// .accessDeniedHandler(this.accessDeniedHandler)) +// .sessionManagement((session) -> +// session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +// .oauth2ResourceServer((resourceServer) -> +// resourceServer +// .authenticationEntryPoint(this.authenticationEntryPoint) +// .bearerTokenResolver(this.bearerTokenResolver) +// .opaqueToken((config) -> config.introspector(this.tokenIntrospector))); +// } +// +// private CorsConfigurationSource corsConfigurationSource() { +// CorsConfiguration corsConfiguration = new CorsConfiguration(); +// corsConfiguration.setAllowedOrigins(this.accessControlAllowedOrigins); +// corsConfiguration.setAllowedMethods(List.of( +// HttpMethod.GET.name(), +// HttpMethod.POST.name(), +// HttpMethod.PUT.name(), +// HttpMethod.DELETE.name(), +// HttpMethod.PATCH.name(), +// HttpMethod.HEAD.name() +// )); +// corsConfiguration.setAllowedHeaders(List.of( +// HttpHeaders.AUTHORIZATION, +// HttpHeaders.COOKIE, +// HttpHeaders.UPGRADE, +// HttpHeaders.CONTENT_TYPE, +// HttpHeaders.ACCEPT, +// HttpMethodOverrideConstant.HEADER_NAME +// )); +// corsConfiguration.setAllowCredentials(true); +// corsConfiguration.setExposedHeaders(List.of( +// HttpHeaders.UPGRADE, +// HttpHeaders.CONTENT_TYPE, +// HttpHeaders.SET_COOKIE +// )); // TODO: specify response headers +// corsConfiguration.setMaxAge(Duration.of(1L, ChronoUnit.HOURS)); +// +// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); +// source.registerCorsConfiguration("/**", corsConfiguration); +// return source; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/config/security/EtcSecurityFilterChainConfig.java b/src/main/java/com/codezerotoone/mvp/global/config/security/EtcSecurityFilterChainConfig.java index a32f2e2..6da790c 100644 --- a/src/main/java/com/codezerotoone/mvp/global/config/security/EtcSecurityFilterChainConfig.java +++ b/src/main/java/com/codezerotoone/mvp/global/config/security/EtcSecurityFilterChainConfig.java @@ -1,76 +1,76 @@ -package com.codezerotoone.mvp.global.config.security; - -import com.codezerotoone.mvp.global.util.methodoverride.HttpMethodOverrideConstant; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.time.Duration; -import java.time.temporal.ChronoUnit; -import java.util.List; - -@Configuration -@EnableWebSecurity -public class EtcSecurityFilterChainConfig { - - @Value("${client.cors.allowed-origins}") - private List clientOrigins; - - @Bean - @Order - public SecurityFilterChain etcSecurityFilterChain(HttpSecurity http) throws Exception { - return http.securityMatcher("/**") - .csrf(AbstractHttpConfigurer::disable) - .cors((configurer) -> configurer.configurationSource(corsConfigurationSource())) - .sessionManagement((config) -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .authorizeHttpRequests((registry) -> -// registry.requestMatchers("/actuator/**", "/error", "/oauth-test.html").permitAll()) // TODO: actuator 필터 설정 - registry.anyRequest().permitAll()) // TODO: is this ok? - .build(); - } - - private CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.setAllowedOrigins(this.clientOrigins); - corsConfiguration.setAllowedMethods(List.of( - HttpMethod.GET.name(), - HttpMethod.POST.name(), - HttpMethod.PUT.name(), - HttpMethod.DELETE.name(), - HttpMethod.PATCH.name(), - HttpMethod.HEAD.name() - )); - corsConfiguration.setAllowedHeaders(List.of( - HttpHeaders.AUTHORIZATION, - HttpHeaders.COOKIE, - HttpHeaders.UPGRADE, - HttpHeaders.CONTENT_TYPE, - HttpHeaders.ACCEPT, - HttpMethodOverrideConstant.HEADER_NAME - )); - corsConfiguration.setAllowCredentials(true); - corsConfiguration.setExposedHeaders(List.of( - HttpHeaders.UPGRADE, - HttpHeaders.CONTENT_TYPE, - HttpHeaders.SET_COOKIE - )); // TODO: specify response headers - corsConfiguration.setMaxAge(Duration.of(1L, ChronoUnit.HOURS)); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", corsConfiguration); - return source; - } -} +//package com.codezerotoone.mvp.global.config.security; +// +//import com.codezerotoone.mvp.global.util.methodoverride.HttpMethodOverrideConstant; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.core.annotation.Order; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpMethod; +//import org.springframework.security.config.annotation.web.builders.HttpSecurity; +//import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +//import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +//import org.springframework.security.config.http.SessionCreationPolicy; +//import org.springframework.security.web.SecurityFilterChain; +//import org.springframework.web.cors.CorsConfiguration; +//import org.springframework.web.cors.CorsConfigurationSource; +//import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +// +//import java.time.Duration; +//import java.time.temporal.ChronoUnit; +//import java.util.List; +// +//@Configuration +//@EnableWebSecurity +//public class EtcSecurityFilterChainConfig { +// +// @Value("${client.cors.allowed-origins}") +// private List clientOrigins; +// +// @Bean +// @Order +// public SecurityFilterChain etcSecurityFilterChain(HttpSecurity http) throws Exception { +// return http.securityMatcher("/**") +// .csrf(AbstractHttpConfigurer::disable) +// .cors((configurer) -> configurer.configurationSource(corsConfigurationSource())) +// .sessionManagement((config) -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +// .formLogin(AbstractHttpConfigurer::disable) +// .httpBasic(AbstractHttpConfigurer::disable) +// .authorizeHttpRequests((registry) -> +//// registry.requestMatchers("/actuator/**", "/error", "/oauth-test.html").permitAll()) // TODO: actuator 필터 설정 +// registry.anyRequest().permitAll()) // TODO: is this ok? +// .build(); +// } +// +// private CorsConfigurationSource corsConfigurationSource() { +// CorsConfiguration corsConfiguration = new CorsConfiguration(); +// corsConfiguration.setAllowedOrigins(this.clientOrigins); +// corsConfiguration.setAllowedMethods(List.of( +// HttpMethod.GET.name(), +// HttpMethod.POST.name(), +// HttpMethod.PUT.name(), +// HttpMethod.DELETE.name(), +// HttpMethod.PATCH.name(), +// HttpMethod.HEAD.name() +// )); +// corsConfiguration.setAllowedHeaders(List.of( +// HttpHeaders.AUTHORIZATION, +// HttpHeaders.COOKIE, +// HttpHeaders.UPGRADE, +// HttpHeaders.CONTENT_TYPE, +// HttpHeaders.ACCEPT, +// HttpMethodOverrideConstant.HEADER_NAME +// )); +// corsConfiguration.setAllowCredentials(true); +// corsConfiguration.setExposedHeaders(List.of( +// HttpHeaders.UPGRADE, +// HttpHeaders.CONTENT_TYPE, +// HttpHeaders.SET_COOKIE +// )); // TODO: specify response headers +// corsConfiguration.setMaxAge(Duration.of(1L, ChronoUnit.HOURS)); +// +// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); +// source.registerCorsConfiguration("/**", corsConfiguration); +// return source; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityBeansConfig.java b/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityBeansConfig.java index b58981e..9e749dd 100644 --- a/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityBeansConfig.java +++ b/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityBeansConfig.java @@ -1,54 +1,54 @@ -package com.codezerotoone.mvp.global.config.security; - -import com.codezerotoone.mvp.domain.member.auth.service.RoleService; -import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; -import com.codezerotoone.mvp.global.security.exceptionhandler.DefaultAccessDeniedHandler; -import com.codezerotoone.mvp.global.security.exceptionhandler.DefaultAuthenticationEntryPoint; -import com.codezerotoone.mvp.global.security.token.introspector.DefaultOpaqueTokenIntrospector; -import com.codezerotoone.mvp.global.security.token.support.JsonTokenSupport; -import com.codezerotoone.mvp.global.security.token.support.TokenSupport; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; -import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.access.AccessDeniedHandler; - -@Configuration -public class SecurityBeansConfig { - - @Bean - @ConditionalOnMissingBean(AuthenticationEntryPoint.class) - public AuthenticationEntryPoint defaultAuthenticationEntryPoint(ObjectMapper objectMapper) { - return new DefaultAuthenticationEntryPoint(objectMapper); - } - - @Bean - @ConditionalOnMissingBean(AccessDeniedHandler.class) - public AccessDeniedHandler defaultAccessDeniedHandler(ObjectMapper objectMapper) { - return new DefaultAccessDeniedHandler(objectMapper); - } - - @Bean - @ConditionalOnMissingBean(OpaqueTokenIntrospector.class) - public OpaqueTokenIntrospector defaultOpaqueTokenIntrospector(TokenSupport tokenSupport, - MemberRepository memberRepository, - RoleService roleService) { - return new DefaultOpaqueTokenIntrospector(tokenSupport, memberRepository, roleService); - } - - @Bean - @ConditionalOnMissingBean(BearerTokenResolver.class) - public BearerTokenResolver defaultBearerTokenResolver() { - return new DefaultBearerTokenResolver(); - } - - @Bean - @ConditionalOnMissingBean(TokenSupport.class) - public TokenSupport jsonTokenSupport(ObjectMapper objectMapper) { - return new JsonTokenSupport(objectMapper); - } -} +//package com.codezerotoone.mvp.global.config.security; +// +//import com.codezerotoone.mvp.domain.member.auth.service.RoleService; +//import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +//import com.codezerotoone.mvp.global.security.exceptionhandler.DefaultAccessDeniedHandler; +//import com.codezerotoone.mvp.global.security.exceptionhandler.DefaultAuthenticationEntryPoint; +//import com.codezerotoone.mvp.global.security.token.introspector.DefaultOpaqueTokenIntrospector; +//import com.codezerotoone.mvp.global.security.token.support.JsonTokenSupport; +//import com.codezerotoone.mvp.global.security.token.support.TokenSupport; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +//import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +//import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; +//import org.springframework.security.web.AuthenticationEntryPoint; +//import org.springframework.security.web.access.AccessDeniedHandler; +// +//@Configuration +//public class SecurityBeansConfig { +// +// @Bean +// @ConditionalOnMissingBean(AuthenticationEntryPoint.class) +// public AuthenticationEntryPoint defaultAuthenticationEntryPoint(ObjectMapper objectMapper) { +// return new DefaultAuthenticationEntryPoint(objectMapper); +// } +// +// @Bean +// @ConditionalOnMissingBean(AccessDeniedHandler.class) +// public AccessDeniedHandler defaultAccessDeniedHandler(ObjectMapper objectMapper) { +// return new DefaultAccessDeniedHandler(objectMapper); +// } +// +// @Bean +// @ConditionalOnMissingBean(OpaqueTokenIntrospector.class) +// public OpaqueTokenIntrospector defaultOpaqueTokenIntrospector(TokenSupport tokenSupport, +// MemberRepository memberRepository, +// RoleService roleService) { +// return new DefaultOpaqueTokenIntrospector(tokenSupport, memberRepository, roleService); +// } +// +// @Bean +// @ConditionalOnMissingBean(BearerTokenResolver.class) +// public BearerTokenResolver defaultBearerTokenResolver() { +// return new DefaultBearerTokenResolver(); +// } +// +// @Bean +// @ConditionalOnMissingBean(TokenSupport.class) +// public TokenSupport jsonTokenSupport(ObjectMapper objectMapper) { +// return new JsonTokenSupport(objectMapper); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityConfig.java b/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityConfig.java new file mode 100644 index 0000000..9666378 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/config/security/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.codezerotoone.mvp.global.config.security; + +import com.codezerotoone.mvp.domain.member.auth.service.CustomAccessDeniedHandler; +import com.codezerotoone.mvp.domain.member.auth.service.CustomAuthenticationEntryPoint; +import com.codezerotoone.mvp.domain.member.auth.service.CustomOAuth2UserService; +import com.codezerotoone.mvp.domain.member.auth.service.CustomSuccessHandler; +import com.codezerotoone.mvp.global.security.filter.JwtFilter; +import com.codezerotoone.mvp.global.util.JwtUtil; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomSuccessHandler customSuccessHandler; + private final JwtUtil jwtUtil; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 + .formLogin(AbstractHttpConfigurer::disable) // Form 로그인 방식 disable + .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 방식 disable + .oauth2Login((oauth2) -> oauth2 + .loginPage("/custom/login") + .userInfoEndpoint( + (userInfoEndpointConfig) -> userInfoEndpointConfig.userService(customOAuth2UserService)) + .successHandler(customSuccessHandler)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 적용 + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증 실패 시 동작 + .accessDeniedHandler(new CustomAccessDeniedHandler())) //권한 부족 시 동작 + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/v1/members") + .hasRole("GUEST") + .requestMatchers("/api/v1/**") + .hasRole("MEMBER") + .requestMatchers(HttpMethod.PATCH, "/api/v1/members/{memberId}/status") + .hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/members/") + .hasRole("ADMIN") + .requestMatchers("/", "/custom/login", "/login", "/oauth2/authorization/**", "/api/v1/auth/refresh", "/api/auth/check/login") + .permitAll() + ) + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http + .addFilterAfter(new JwtFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins( + List.of("http://localhost:3000", "http://localhost:8080")); // 허용할 Origin + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); // 허용할 HTTP 메서드 + configuration.setAllowedHeaders(List.of("*")); // 허용할 헤더 + configuration.setExposedHeaders(List.of("*")); // 노출할 헤더 + configuration.setAllowCredentials(true); // 인증 정보 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용 + return source; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/config/security/SwaggerUiSecurityFilterChainConfig.java b/src/main/java/com/codezerotoone/mvp/global/config/security/SwaggerUiSecurityFilterChainConfig.java index 099ea47..f154893 100644 --- a/src/main/java/com/codezerotoone/mvp/global/config/security/SwaggerUiSecurityFilterChainConfig.java +++ b/src/main/java/com/codezerotoone/mvp/global/config/security/SwaggerUiSecurityFilterChainConfig.java @@ -1,30 +1,30 @@ -package com.codezerotoone.mvp.global.config.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SwaggerUiSecurityFilterChainConfig { - - @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain swaggerUiSecurityFilterChain(HttpSecurity http) throws Exception { - // TODO: Swagger UI에 대한 접근 보안 설정 필요 - return http.securityMatcher("/swagger-ui/**", "/v3/**") - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement((config) -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .authorizeHttpRequests((registry) -> - registry.anyRequest().permitAll()) - .build(); - } -} +//package com.codezerotoone.mvp.global.config.security; +// +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.core.Ordered; +//import org.springframework.core.annotation.Order; +//import org.springframework.security.config.annotation.web.builders.HttpSecurity; +//import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +//import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +//import org.springframework.security.config.http.SessionCreationPolicy; +//import org.springframework.security.web.SecurityFilterChain; +// +//@Configuration +//@EnableWebSecurity +//public class SwaggerUiSecurityFilterChainConfig { +// +// @Bean +// @Order(Ordered.HIGHEST_PRECEDENCE) +// public SecurityFilterChain swaggerUiSecurityFilterChain(HttpSecurity http) throws Exception { +// // TODO: Swagger UI에 대한 접근 보안 설정 필요 +// return http.securityMatcher("/swagger-ui/**", "/v3/**") +// .csrf(AbstractHttpConfigurer::disable) +// .sessionManagement((config) -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +// .formLogin(AbstractHttpConfigurer::disable) +// .httpBasic(AbstractHttpConfigurer::disable) +// .authorizeHttpRequests((registry) -> +// registry.anyRequest().permitAll()) +// .build(); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/filter/AuthenticatedRequestCheckFilter.java b/src/main/java/com/codezerotoone/mvp/global/security/filter/AuthenticatedRequestCheckFilter.java index c81ef92..cce4999 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/filter/AuthenticatedRequestCheckFilter.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/filter/AuthenticatedRequestCheckFilter.java @@ -1,95 +1,95 @@ -package com.codezerotoone.mvp.global.security.filter; - -import com.codezerotoone.mvp.domain.member.auth.constant.AuthorizedHttpMethod; -import com.codezerotoone.mvp.domain.member.auth.constant.PrimaryRole; -import com.codezerotoone.mvp.domain.member.auth.dto.response.AllowedEndpointForRole; -import com.codezerotoone.mvp.domain.member.auth.service.RoleService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.*; - -// TODO: 이렇게 하는 것보다 Spring Security에서 설정하는 방법이 있는지 찾아보기 -@RequiredArgsConstructor -@Slf4j -public class AuthenticatedRequestCheckFilter extends OncePerRequestFilter { - private static final GrantedAuthority ANONYMOUS_AUTHORITY = new SimpleGrantedAuthority(PrimaryRole.ROLE_ANONYMOUS.name()); - - private final RoleService roleService; - private final AuthenticationEntryPoint authenticationEntryPoint; - private final Set antPathRequestMatchers = new HashSet<>(); - - @Override - public void initFilterBean() throws ServletException { - this.antPathRequestMatchers.clear(); - - Map> allAccessPermission = this.roleService.getAllAccessPermission(); - - // TODO: O(N) - allAccessPermission.forEach((httpMethod, allowedEndpointForRoles) -> { - for (AllowedEndpointForRole allowedEndpointForRole : allowedEndpointForRoles) { - this.antPathRequestMatchers.add(new AntPathRequestMatcher(allowedEndpointForRole.endpoint(), httpMethod.name())); - } - }); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - processFilter(request, response, filterChain); - return; - } - - Object principal = authentication.getPrincipal(); - if (principal == null) { - processFilter(request, response, filterChain); - return; - } - - OAuth2AuthenticatedPrincipal oAuth2AuthenticatedPrincipal = (OAuth2AuthenticatedPrincipal) principal; - - Collection authorities = oAuth2AuthenticatedPrincipal.getAuthorities(); - - if ((authorities == null || authorities.contains(ANONYMOUS_AUTHORITY)) - && !isInWhiteList(request)) { - this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("Invalid access token")); - return; - } - - filterChain.doFilter(request, response); - } - - private void processFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - if (isInWhiteList(request)) { - filterChain.doFilter(request, response); - } else { - this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("Invalid access token")); - } - } - - private boolean isInWhiteList(HttpServletRequest request) { - for (AntPathRequestMatcher matcher : this.antPathRequestMatchers) { - if (matcher.matches(request)) { - return false; - } - } - return true; - } -} +//package com.codezerotoone.mvp.global.security.filter; +// +//import com.codezerotoone.mvp.domain.member.auth.constant.AuthorizedHttpMethod; +//import com.codezerotoone.mvp.domain.member.auth.constant.PrimaryRole; +//import com.codezerotoone.mvp.domain.member.auth.dto.response.AllowedEndpointForRole; +//import com.codezerotoone.mvp.domain.member.auth.service.RoleService; +//import jakarta.servlet.FilterChain; +//import jakarta.servlet.ServletException; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpServletResponse; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.security.authentication.BadCredentialsException; +//import org.springframework.security.core.Authentication; +//import org.springframework.security.core.GrantedAuthority; +//import org.springframework.security.core.authority.SimpleGrantedAuthority; +//import org.springframework.security.core.context.SecurityContextHolder; +//import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +//import org.springframework.security.web.AuthenticationEntryPoint; +//import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +//import org.springframework.web.filter.OncePerRequestFilter; +// +//import java.io.IOException; +//import java.util.*; +// +//// TODO: 이렇게 하는 것보다 Spring Security에서 설정하는 방법이 있는지 찾아보기 +//@RequiredArgsConstructor +//@Slf4j +//public class AuthenticatedRequestCheckFilter extends OncePerRequestFilter { +// private static final GrantedAuthority ANONYMOUS_AUTHORITY = new SimpleGrantedAuthority(PrimaryRole.ROLE_ANONYMOUS.name()); +// +// private final RoleService roleService; +// private final AuthenticationEntryPoint authenticationEntryPoint; +// private final Set antPathRequestMatchers = new HashSet<>(); +// +// @Override +// public void initFilterBean() throws ServletException { +// this.antPathRequestMatchers.clear(); +// +// Map> allAccessPermission = this.roleService.getAllAccessPermission(); +// +// // TODO: O(N) +// allAccessPermission.forEach((httpMethod, allowedEndpointForRoles) -> { +// for (AllowedEndpointForRole allowedEndpointForRole : allowedEndpointForRoles) { +// this.antPathRequestMatchers.add(new AntPathRequestMatcher(allowedEndpointForRole.endpoint(), httpMethod.name())); +// } +// }); +// } +// +// @Override +// protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) +// throws ServletException, IOException { +// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); +// if (authentication == null) { +// processFilter(request, response, filterChain); +// return; +// } +// +// Object principal = authentication.getPrincipal(); +// if (principal == null) { +// processFilter(request, response, filterChain); +// return; +// } +// +// OAuth2AuthenticatedPrincipal oAuth2AuthenticatedPrincipal = (OAuth2AuthenticatedPrincipal) principal; +// +// Collection authorities = oAuth2AuthenticatedPrincipal.getAuthorities(); +// +// if ((authorities == null || authorities.contains(ANONYMOUS_AUTHORITY)) +// && !isInWhiteList(request)) { +// this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("Invalid access token")); +// return; +// } +// +// filterChain.doFilter(request, response); +// } +// +// private void processFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) +// throws ServletException, IOException { +// if (isInWhiteList(request)) { +// filterChain.doFilter(request, response); +// } else { +// this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("Invalid access token")); +// } +// } +// +// private boolean isInWhiteList(HttpServletRequest request) { +// for (AntPathRequestMatcher matcher : this.antPathRequestMatchers) { +// if (matcher.matches(request)) { +// return false; +// } +// } +// return true; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/filter/JwtFilter.java b/src/main/java/com/codezerotoone/mvp/global/security/filter/JwtFilter.java new file mode 100644 index 0000000..9f4fda6 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/security/filter/JwtFilter.java @@ -0,0 +1,75 @@ +package com.codezerotoone.mvp.global.security.filter; + +import com.codezerotoone.mvp.domain.member.auth.dto.CustomOAuth2User; +import com.codezerotoone.mvp.domain.member.member.dto.MemberDto; +import com.codezerotoone.mvp.global.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Slf4j +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String requestURI = request.getRequestURI(); + + if (requestURI.endsWith("/api/auth/refresh")) { + filterChain.doFilter(request, response); + return; + } + + String token = getTokenFromCookies(request, "Authorization"); + + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + if (jwtUtil.isExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Long memberId = jwtUtil.getMemberId(token); + String role = jwtUtil.getRole(token); + + MemberDto memberDto = MemberDto.builder() + .memberId(memberId) + .role(role) + .build(); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(memberDto); + + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } + + private String getTokenFromCookies(HttpServletRequest request, String cookieName) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (cookie.getName().equals(cookieName)) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java b/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java index 746e7e8..694c12b 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java @@ -1,61 +1,61 @@ -package com.codezerotoone.mvp.global.security.token.introspector; - -import com.codezerotoone.mvp.domain.member.auth.constant.PrimaryRole; -import com.codezerotoone.mvp.domain.member.auth.service.RoleService; -import com.codezerotoone.mvp.domain.member.member.entity.Member; -import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.support.TokenSupport; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@RequiredArgsConstructor -@Slf4j -public class DefaultOpaqueTokenIntrospector implements OpaqueTokenIntrospector { - private final TokenSupport tokenSupport; - private final MemberRepository memberRepository; - private final RoleService roleService; - - @Override - public OAuth2AuthenticatedPrincipal introspect(String token) { - try { - OAuth2AuthenticationInfo userInfo = this.tokenSupport.authenticate(token); - String oidcId = userInfo.id(); - Optional memberOp = this.memberRepository.findByOdicId(oidcId); - - if (memberOp.isPresent()) { - Member member = memberOp.get(); - - return new DefaultOAuth2AuthenticatedPrincipal( - String.valueOf(member.getMemberId()), - Map.of("sub", oidcId == null ? "" : oidcId), - List.of(new SimpleGrantedAuthority(member.getRole().getRoleId())) - ); - } - - // If memberOp is empty - // 로그인을 한 회원이지만, 가입된 회원이 아닐 경우 - return new DefaultOAuth2AuthenticatedPrincipal( - "-1", - Map.of("sub", oidcId == null ? "" : oidcId), - List.of(new SimpleGrantedAuthority(PrimaryRole.ROLE_GUEST.name())) - ); - } catch (InvalidAccessTokenException e) { - return new DefaultOAuth2AuthenticatedPrincipal( - "-1", - Map.of("sub", ""), - List.of(new SimpleGrantedAuthority(PrimaryRole.ROLE_ANONYMOUS.name())) - ); -// throw new BadCredentialsException("Invalid access token", e); - } - } -} +//package com.codezerotoone.mvp.global.security.token.introspector; +// +//import com.codezerotoone.mvp.domain.member.auth.constant.PrimaryRole; +//import com.codezerotoone.mvp.domain.member.auth.service.RoleService; +//import com.codezerotoone.mvp.domain.member.member.entity.Member; +//import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.support.TokenSupport; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.security.core.authority.SimpleGrantedAuthority; +//import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +//import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +//import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +// +//import java.util.List; +//import java.util.Map; +//import java.util.Optional; +// +//@RequiredArgsConstructor +//@Slf4j +//public class DefaultOpaqueTokenIntrospector implements OpaqueTokenIntrospector { +// private final TokenSupport tokenSupport; +// private final MemberRepository memberRepository; +// private final RoleService roleService; +// +// @Override +// public OAuth2AuthenticatedPrincipal introspect(String token) { +// try { +// OAuth2AuthenticationInfo userInfo = this.tokenSupport.authenticate(token); +// String oidcId = userInfo.id(); +// Optional memberOp = this.memberRepository.findByOdicId(oidcId); +// +// if (memberOp.isPresent()) { +// Member member = memberOp.get(); +// +// return new DefaultOAuth2AuthenticatedPrincipal( +// String.valueOf(member.getMemberId()), +// Map.of("sub", oidcId == null ? "" : oidcId), +// List.of(new SimpleGrantedAuthority(member.getRole().getRoleId())) +// ); +// } +// +// // If memberOp is empty +// // 로그인을 한 회원이지만, 가입된 회원이 아닐 경우 +// return new DefaultOAuth2AuthenticatedPrincipal( +// "-1", +// Map.of("sub", oidcId == null ? "" : oidcId), +// List.of(new SimpleGrantedAuthority(PrimaryRole.ROLE_GUEST.name())) +// ); +// } catch (InvalidAccessTokenException e) { +// return new DefaultOAuth2AuthenticatedPrincipal( +// "-1", +// Map.of("sub", ""), +// List.of(new SimpleGrantedAuthority(PrimaryRole.ROLE_ANONYMOUS.name())) +// ); +//// throw new BadCredentialsException("Invalid access token", e); +// } +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/resolver/JsonBearerTokenResolver.java b/src/main/java/com/codezerotoone/mvp/global/security/token/resolver/JsonBearerTokenResolver.java index c9c111f..c23f9dd 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/resolver/JsonBearerTokenResolver.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/resolver/JsonBearerTokenResolver.java @@ -1,23 +1,23 @@ -package com.codezerotoone.mvp.global.security.token.resolver; - -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpHeaders; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; -import org.springframework.stereotype.Component; - -@Component -@Profile("!qa.test & !prod") -@RequiredArgsConstructor -public class JsonBearerTokenResolver implements BearerTokenResolver { - - @Override - public String resolve(HttpServletRequest request) { - String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); - if (authorization == null) { - return null; - } - return authorization.startsWith("Bearer ") ? authorization.substring(7) : authorization; - } -} +//package com.codezerotoone.mvp.global.security.token.resolver; +// +//import jakarta.servlet.http.HttpServletRequest; +//import lombok.RequiredArgsConstructor; +//import org.springframework.context.annotation.Profile; +//import org.springframework.http.HttpHeaders; +//import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +//import org.springframework.stereotype.Component; +// +//@Component +//@Profile("!qa.test & !prod") +//@RequiredArgsConstructor +//public class JsonBearerTokenResolver implements BearerTokenResolver { +// +// @Override +// public String resolve(HttpServletRequest request) { +// String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); +// if (authorization == null) { +// return null; +// } +// return authorization.startsWith("Bearer ") ? authorization.substring(7) : authorization; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/DelegatingTokenSupport.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/DelegatingTokenSupport.java index e6c4879..e4e99d6 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/DelegatingTokenSupport.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/DelegatingTokenSupport.java @@ -1,86 +1,86 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * TokenProcessor에게 역할을 위임하는 TokenSupport. - * 아마 Spring Security에서 설정 파일을 통해 OAuth 2.0 vendor에 따라 설정하는 방법이 있을 텐데... - * - * @see RestTemplateGoogleTokenProcessor - * @see RestTemplateKakaoTokenProcessor - */ -@Component -@Profile("qa.test | prod") -@Slf4j -public class DelegatingTokenSupport implements TokenSupport { - private final List processors; - private final Map processorByAuthVendor; - - public DelegatingTokenSupport(List processors) { - this.processors = processors; - this.processorByAuthVendor = processors.stream() - .collect(Collectors.toMap(TokenProcessor::getAuthVendor, (p) -> p)); - } - - @Override - public GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException { - TokenProcessor processor = this.processorByAuthVendor.get(authVendor); - if (processor == null) { - throw new UnsupportedCodeException("No Auth vendor for: " + authVendor); - } - return processor.grantToken(code, redirectUri); - } - - @Override - public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { - for (TokenProcessor processor : processors) { - try { - OAuth2AuthenticationInfo authentication = processor.authenticate(accessToken); - log.info("Authenticated by: {}", processor.getAuthVendor()); - return authentication; - } catch (InvalidAccessTokenException e) { - log.info("Access token not supported by: {}", processor.getAuthVendor()); - } - } - throw new InvalidAccessTokenException("유효하지 않은 토큰"); - } - - @Override - public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { - for (TokenProcessor processor : processors) { - try { - OAuth2UserInfo user = processor.retrieveUserInfo(accessToken); - log.info("Signed in with: {}", processor.getAuthVendor()); - return user; - } catch (InvalidAccessTokenException e) { - log.info("Access token not supported by: {}", processor.getAuthVendor()); - } - } - throw new InvalidAccessTokenException("Invalid access token"); - } - - @Override - public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { - for (TokenProcessor processor : processors) { - try { - return processor.refreshToken(refreshToken); - } catch (InvalidRefreshTokenException e) { - log.info("Refresh token not supported by: {}", processor.getAuthVendor()); - } - } - throw new InvalidRefreshTokenException("Invalid refresh token"); - } -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.context.annotation.Profile; +//import org.springframework.stereotype.Component; +// +//import java.util.List; +//import java.util.Map; +//import java.util.stream.Collectors; +// +///** +// * TokenProcessor에게 역할을 위임하는 TokenSupport. +// * 아마 Spring Security에서 설정 파일을 통해 OAuth 2.0 vendor에 따라 설정하는 방법이 있을 텐데... +// * +// * @see RestTemplateGoogleTokenProcessor +// * @see RestTemplateKakaoTokenProcessor +// */ +//@Component +//@Profile("qa.test | prod") +//@Slf4j +//public class DelegatingTokenSupport implements TokenSupport { +// private final List processors; +// private final Map processorByAuthVendor; +// +// public DelegatingTokenSupport(List processors) { +// this.processors = processors; +// this.processorByAuthVendor = processors.stream() +// .collect(Collectors.toMap(TokenProcessor::getAuthVendor, (p) -> p)); +// } +// +// @Override +// public GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException { +// TokenProcessor processor = this.processorByAuthVendor.get(authVendor); +// if (processor == null) { +// throw new UnsupportedCodeException("No Auth vendor for: " + authVendor); +// } +// return processor.grantToken(code, redirectUri); +// } +// +// @Override +// public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { +// for (TokenProcessor processor : processors) { +// try { +// OAuth2AuthenticationInfo authentication = processor.authenticate(accessToken); +// log.info("Authenticated by: {}", processor.getAuthVendor()); +// return authentication; +// } catch (InvalidAccessTokenException e) { +// log.info("Access token not supported by: {}", processor.getAuthVendor()); +// } +// } +// throw new InvalidAccessTokenException("유효하지 않은 토큰"); +// } +// +// @Override +// public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { +// for (TokenProcessor processor : processors) { +// try { +// OAuth2UserInfo user = processor.retrieveUserInfo(accessToken); +// log.info("Signed in with: {}", processor.getAuthVendor()); +// return user; +// } catch (InvalidAccessTokenException e) { +// log.info("Access token not supported by: {}", processor.getAuthVendor()); +// } +// } +// throw new InvalidAccessTokenException("Invalid access token"); +// } +// +// @Override +// public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { +// for (TokenProcessor processor : processors) { +// try { +// return processor.refreshToken(refreshToken); +// } catch (InvalidRefreshTokenException e) { +// log.info("Refresh token not supported by: {}", processor.getAuthVendor()); +// } +// } +// throw new InvalidRefreshTokenException("Invalid refresh token"); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/GoogleResponse.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/GoogleResponse.java new file mode 100644 index 0000000..4339e09 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/GoogleResponse.java @@ -0,0 +1,37 @@ +package com.codezerotoone.mvp.global.security.token.support; + +import java.util.Map; + +public class GoogleResponse implements OAuth2Response { + + private final Map attribute; + + public GoogleResponse(Map attribute) { + this.attribute = attribute; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } + + @Override + public String getProfileImage() { + return attribute.get("picture").toString(); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/JsonTokenSupport.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/JsonTokenSupport.java index 0b8ecbf..8f9e6d1 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/JsonTokenSupport.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/JsonTokenSupport.java @@ -1,77 +1,77 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.net.URLDecoder; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -/** - * JSON 토큰을 처리하는 역할을 지닌 {@code TokenSupport} 구현체. - * {@link com.codezerotoone.mvp.global.config.security.SecurityBeansConfig}에서 Spring Bean으로 등록된다. - * - * @author PGD - * @see com.codezerotoone.mvp.global.config.security.SecurityBeansConfig - */ -@RequiredArgsConstructor -@Slf4j -public class JsonTokenSupport implements TokenSupport { - private final ObjectMapper objectMapper; - - @Override - public GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException { - String trimmedCode = code.substring(0, 20); - String token = URLEncoder.encode(String.format("{ \"id\": \"%s\", \"name\": \"안유진\", \"profileImageUrl\": \"https://dimg.donga.com/wps/NEWS/IMAGE/2020/12/09/104244741.2.jpg\"}", trimmedCode), StandardCharsets.UTF_8); - return GrantedTokenInfo.builder() - .accessToken(token) - .refreshToken(token) - .id(trimmedCode) - .authVendor(AuthVendor.NATIVE) - .build(); - } - - @Override - public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { - try { - Map result = this.objectMapper.readValue(accessToken, new TypeReference<>() { - }); - - return OAuth2AuthenticationInfo.builder() - .id(result.get("id")) - .build(); - } catch (JsonProcessingException e) { - throw new InvalidAccessTokenException(e); - } - } - - @Override - public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { - try { - return this.objectMapper.readValue(URLDecoder.decode(accessToken, StandardCharsets.UTF_8), OAuth2UserInfo.class); - } catch (JsonProcessingException e) { - throw new InvalidAccessTokenException("Invalid Access token", e); - } - } - - @Override - public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { - return GrantedTokenInfo.builder() - .accessToken(refreshToken) - .refreshToken(refreshToken) - .id(authenticate(refreshToken).id()) - .authVendor(AuthVendor.NATIVE) - .build(); - } -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import com.fasterxml.jackson.core.JsonProcessingException; +//import com.fasterxml.jackson.core.type.TypeReference; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +// +//import java.net.URLDecoder; +//import java.net.URLEncoder; +//import java.nio.charset.StandardCharsets; +//import java.util.Map; +// +///** +// * JSON 토큰을 처리하는 역할을 지닌 {@code TokenSupport} 구현체. +// * {@link com.codezerotoone.mvp.global.config.security.SecurityBeansConfig}에서 Spring Bean으로 등록된다. +// * +// * @author PGD +// * @see com.codezerotoone.mvp.global.config.security.SecurityBeansConfig +// */ +//@RequiredArgsConstructor +//@Slf4j +//public class JsonTokenSupport implements TokenSupport { +// private final ObjectMapper objectMapper; +// +// @Override +// public GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException { +// String trimmedCode = code.substring(0, 20); +// String token = URLEncoder.encode(String.format("{ \"id\": \"%s\", \"name\": \"안유진\", \"profileImageUrl\": \"https://dimg.donga.com/wps/NEWS/IMAGE/2020/12/09/104244741.2.jpg\"}", trimmedCode), StandardCharsets.UTF_8); +// return GrantedTokenInfo.builder() +// .accessToken(token) +// .refreshToken(token) +// .id(trimmedCode) +// .authVendor(AuthVendor.NATIVE) +// .build(); +// } +// +// @Override +// public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { +// try { +// Map result = this.objectMapper.readValue(accessToken, new TypeReference<>() { +// }); +// +// return OAuth2AuthenticationInfo.builder() +// .id(result.get("id")) +// .build(); +// } catch (JsonProcessingException e) { +// throw new InvalidAccessTokenException(e); +// } +// } +// +// @Override +// public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { +// try { +// return this.objectMapper.readValue(URLDecoder.decode(accessToken, StandardCharsets.UTF_8), OAuth2UserInfo.class); +// } catch (JsonProcessingException e) { +// throw new InvalidAccessTokenException("Invalid Access token", e); +// } +// } +// +// @Override +// public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { +// return GrantedTokenInfo.builder() +// .accessToken(refreshToken) +// .refreshToken(refreshToken) +// .id(authenticate(refreshToken).id()) +// .authVendor(AuthVendor.NATIVE) +// .build(); +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/KakaoResponse.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/KakaoResponse.java new file mode 100644 index 0000000..5c712d4 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/KakaoResponse.java @@ -0,0 +1,52 @@ +package com.codezerotoone.mvp.global.security.token.support; + +import java.util.Map; + +public class KakaoResponse implements OAuth2Response { + + private final Map attributes; + + public KakaoResponse(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + return null; + } + + @Override + public String getName() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + if (kakaoAccount != null && kakaoAccount.containsKey("profile")) { + Map profile = (Map) kakaoAccount.get("profile"); + return profile != null ? profile.get("nickname").toString() : null; + } + + + Map properties = (Map) attributes.get("properties"); + return properties != null ? properties.get("nickname").toString() : null; + } + + @Override + public String getProfileImage() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + if (kakaoAccount != null && kakaoAccount.containsKey("profile")) { + Map profile = (Map) kakaoAccount.get("profile"); + return profile != null ? profile.get("profile_image_url").toString() : null; + } + + Map properties = (Map) attributes.get("properties"); + return properties != null ? properties.get("profile_image").toString() : null; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/OAuth2Response.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/OAuth2Response.java new file mode 100644 index 0000000..ec7ce82 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/OAuth2Response.java @@ -0,0 +1,9 @@ +package com.codezerotoone.mvp.global.security.token.support; + +public interface OAuth2Response { + String getProvider(); //제공자 (google, kakao) + String getProviderId(); + String getEmail(); + String getName(); + String getProfileImage(); +} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateGoogleTokenProcessor.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateGoogleTokenProcessor.java index f7372b8..d34a014 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateGoogleTokenProcessor.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateGoogleTokenProcessor.java @@ -1,164 +1,164 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.dto.external.OAuth2GrantedToken; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import lombok.extern.slf4j.Slf4j; -import org.json.JSONException; -import org.json.JSONObject; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -@Component -@Profile("qa.test | prod") -@Slf4j -public class RestTemplateGoogleTokenProcessor implements TokenProcessor { - private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; - private static final String GOOGLE_ME_URL = "https://www.googleapis.com/userinfo/v2/me"; - private static final String GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; - - private final RestTemplate restTemplate; - private final String clientId; - private final String clientSecret; - - public RestTemplateGoogleTokenProcessor(RestTemplate restTemplate, - @Value("${oauth2.google.client-id}") String clientId, - @Value("${oauth2.google.client-secret}")String clientSecret) { - this.restTemplate = restTemplate; - this.clientId = clientId; - this.clientSecret = clientSecret; - } - - @Override - public GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException { - MultiValueMap requestBody = new LinkedMultiValueMap<>(); - requestBody.add("grant_type", "authorization_code"); - requestBody.add("code", code); - requestBody.add("redirect_uri", redirectUri); - requestBody.add("client_id", this.clientId); - requestBody.add("client_secret", this.clientSecret); - - RequestEntity requestEntity = RequestEntity.post(GOOGLE_TOKEN_URL) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(requestBody); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new UnsupportedCodeException("Code is invalid"); - } - - OAuth2GrantedToken responseBody = responseEntity.getBody(); - - return GrantedTokenInfo.builder() - .accessToken(responseBody.getAccessToken()) - .refreshToken(responseBody.getRefreshToken()) - .id(extractIdFromIdToken(responseBody.getIdToken())) - .authVendor(AuthVendor.GOOGLE) - .build(); - } - - private String extractIdFromIdToken(String idToken) { - // 참고: https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc-id-token - if (log.isDebugEnabled()) { - log.debug("idToken={}", idToken); - } - ResponseEntity response = this.restTemplate.getForEntity("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken, - String.class); - return new JSONObject(response.getBody()).getString("sub"); - } - - @Override - public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { - RequestEntity requestEntity = RequestEntity.get(GOOGLE_ME_URL) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .build(); - - ResponseEntity responseEntity = this.restTemplate.exchange(requestEntity, String.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new InvalidAccessTokenException("Access Token is not valid"); - } - - JSONObject responseBody = new JSONObject(responseEntity.getBody()); - - return OAuth2AuthenticationInfo.builder() - .id(responseBody.getString("id")) - .build(); - } - - @Override - public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { - RequestEntity requestEntity = RequestEntity.get(GOOGLE_USER_INFO_URL) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .build(); - - ResponseEntity responseEntity = this.restTemplate.exchange(requestEntity, String.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - if (log.isDebugEnabled()) { - log.debug("status code: {}, body:\n{}", responseEntity.getStatusCode(), responseEntity.getBody()); - } - throw new InvalidAccessTokenException("Access Token is not valid"); - } - - JSONObject jsonObject = new JSONObject(responseEntity.getBody()); - - try { - return OAuth2UserInfo.builder() - .id(jsonObject.getString("id")) - .name(jsonObject.getString("name")) - .profileImageUrl(jsonObject.getString("picture")) - .build(); - } catch (JSONException e) { - throw new RuntimeException("구글 로그인 서비스에서 줘야 할 걸 안 줌:\n" + jsonObject, e); - } - } - - @Override - public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { - MultiValueMap requestBody = new LinkedMultiValueMap<>(); - requestBody.add("grant_type", "refresh_token"); - requestBody.add("refresh_token", refreshToken); - requestBody.add("client_id", this.clientId); - requestBody.add("client_secret", this.clientSecret); - - RequestEntity requestEntity = RequestEntity.post(GOOGLE_TOKEN_URL) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(requestBody); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new InvalidRefreshTokenException("Refresh token is not valid"); - } - - OAuth2GrantedToken responseBody = responseEntity.getBody(); - - return GrantedTokenInfo.builder() - .accessToken(responseBody.getAccessToken()) - .refreshToken(responseBody.getRefreshToken()) - .id(extractIdFromIdToken(responseBody.getIdToken())) - .authVendor(AuthVendor.GOOGLE) - .build(); - } - - @Override - public AuthVendor getAuthVendor() { - return AuthVendor.GOOGLE; - } -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.dto.external.OAuth2GrantedToken; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import lombok.extern.slf4j.Slf4j; +//import org.json.JSONException; +//import org.json.JSONObject; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.context.annotation.Profile; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.MediaType; +//import org.springframework.http.RequestEntity; +//import org.springframework.http.ResponseEntity; +//import org.springframework.stereotype.Component; +//import org.springframework.util.LinkedMultiValueMap; +//import org.springframework.util.MultiValueMap; +//import org.springframework.web.client.RestTemplate; +// +//@Component +//@Profile("qa.test | prod") +//@Slf4j +//public class RestTemplateGoogleTokenProcessor implements TokenProcessor { +// private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +// private static final String GOOGLE_ME_URL = "https://www.googleapis.com/userinfo/v2/me"; +// private static final String GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; +// +// private final RestTemplate restTemplate; +// private final String clientId; +// private final String clientSecret; +// +// public RestTemplateGoogleTokenProcessor(RestTemplate restTemplate, +// @Value("${oauth2.google.client-id}") String clientId, +// @Value("${oauth2.google.client-secret}")String clientSecret) { +// this.restTemplate = restTemplate; +// this.clientId = clientId; +// this.clientSecret = clientSecret; +// } +// +// @Override +// public GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException { +// MultiValueMap requestBody = new LinkedMultiValueMap<>(); +// requestBody.add("grant_type", "authorization_code"); +// requestBody.add("code", code); +// requestBody.add("redirect_uri", redirectUri); +// requestBody.add("client_id", this.clientId); +// requestBody.add("client_secret", this.clientSecret); +// +// RequestEntity requestEntity = RequestEntity.post(GOOGLE_TOKEN_URL) +// .contentType(MediaType.APPLICATION_FORM_URLENCODED) +// .body(requestBody); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new UnsupportedCodeException("Code is invalid"); +// } +// +// OAuth2GrantedToken responseBody = responseEntity.getBody(); +// +// return GrantedTokenInfo.builder() +// .accessToken(responseBody.getAccessToken()) +// .refreshToken(responseBody.getRefreshToken()) +// .id(extractIdFromIdToken(responseBody.getIdToken())) +// .authVendor(AuthVendor.GOOGLE) +// .build(); +// } +// +// private String extractIdFromIdToken(String idToken) { +// // 참고: https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc-id-token +// if (log.isDebugEnabled()) { +// log.debug("idToken={}", idToken); +// } +// ResponseEntity response = this.restTemplate.getForEntity("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken, +// String.class); +// return new JSONObject(response.getBody()).getString("sub"); +// } +// +// @Override +// public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { +// RequestEntity requestEntity = RequestEntity.get(GOOGLE_ME_URL) +// .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) +// .build(); +// +// ResponseEntity responseEntity = this.restTemplate.exchange(requestEntity, String.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new InvalidAccessTokenException("Access Token is not valid"); +// } +// +// JSONObject responseBody = new JSONObject(responseEntity.getBody()); +// +// return OAuth2AuthenticationInfo.builder() +// .id(responseBody.getString("id")) +// .build(); +// } +// +// @Override +// public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { +// RequestEntity requestEntity = RequestEntity.get(GOOGLE_USER_INFO_URL) +// .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) +// .build(); +// +// ResponseEntity responseEntity = this.restTemplate.exchange(requestEntity, String.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// if (log.isDebugEnabled()) { +// log.debug("status code: {}, body:\n{}", responseEntity.getStatusCode(), responseEntity.getBody()); +// } +// throw new InvalidAccessTokenException("Access Token is not valid"); +// } +// +// JSONObject jsonObject = new JSONObject(responseEntity.getBody()); +// +// try { +// return OAuth2UserInfo.builder() +// .id(jsonObject.getString("id")) +// .name(jsonObject.getString("name")) +// .profileImageUrl(jsonObject.getString("picture")) +// .build(); +// } catch (JSONException e) { +// throw new RuntimeException("구글 로그인 서비스에서 줘야 할 걸 안 줌:\n" + jsonObject, e); +// } +// } +// +// @Override +// public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { +// MultiValueMap requestBody = new LinkedMultiValueMap<>(); +// requestBody.add("grant_type", "refresh_token"); +// requestBody.add("refresh_token", refreshToken); +// requestBody.add("client_id", this.clientId); +// requestBody.add("client_secret", this.clientSecret); +// +// RequestEntity requestEntity = RequestEntity.post(GOOGLE_TOKEN_URL) +// .contentType(MediaType.APPLICATION_FORM_URLENCODED) +// .body(requestBody); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new InvalidRefreshTokenException("Refresh token is not valid"); +// } +// +// OAuth2GrantedToken responseBody = responseEntity.getBody(); +// +// return GrantedTokenInfo.builder() +// .accessToken(responseBody.getAccessToken()) +// .refreshToken(responseBody.getRefreshToken()) +// .id(extractIdFromIdToken(responseBody.getIdToken())) +// .authVendor(AuthVendor.GOOGLE) +// .build(); +// } +// +// @Override +// public AuthVendor getAuthVendor() { +// return AuthVendor.GOOGLE; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateKakaoTokenProcessor.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateKakaoTokenProcessor.java index ef78710..a20e093 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateKakaoTokenProcessor.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/RestTemplateKakaoTokenProcessor.java @@ -1,187 +1,187 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.dto.external.OAuth2GrantedToken; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; -import lombok.extern.slf4j.Slf4j; -import org.json.JSONObject; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.util.Base64; - -@Component -@Profile("qa.test | prod") -@Slf4j -public class RestTemplateKakaoTokenProcessor implements TokenProcessor { - private static final String KAKAO_TOKEN_REQUEST_URL = "https://kauth.kakao.com/oauth/token"; - private static final String KAKAO_OIDC_USER_INFO_URL = "https://kapi.kakao.com/v1/oidc/userinfo"; - private static final String KAKAO_ME_URL = "https://kapi.kakao.com/v2/user/me"; - private static final String TOKEN_REQUEST_HEADER_KEY_GRANT_TYPE = "grant_type"; - private static final String TOKEN_REQUEST_HEADER_VALUE_GRANT_TYPE = "authorization_code"; - private static final String TOKEN_REQUEST_HEADER_KEY_CLIENT_ID = "client_id"; - private static final String TOKEN_REQUEST_HEADER_KEY_CLIENT_SECRET = "client_secret"; - private static final String TOKEN_REQUEST_HEADER_KEY_REDIRECT_URI = "redirect_uri"; - private static final String TOKEN_REQUEST_HEADER_KEY_CODE = "code"; - - private final RestTemplate restTemplate; - private final String kakaoClientId; - private final String kakaoClientSecret; // Nullable - - public RestTemplateKakaoTokenProcessor(RestTemplate restTemplate, - @Value("${oauth2.kakao.client-id}") String kakaoClientId, - @Value("${oauth2.kakao.client-secret}") String kakaoClientSecret) { - this.restTemplate = restTemplate; - this.kakaoClientId = kakaoClientId; - this.kakaoClientSecret = "empty".equals(kakaoClientSecret) ? null : kakaoClientSecret; - } - - @Override - public GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException { - log.debug("redirect uri: {}", redirectUri); - - RequestEntity> requestEntity = buildTokenRequestEntity(code, redirectUri); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new UnsupportedCodeException("Code is not supported."); - } - - OAuth2GrantedToken responseBody = responseEntity.getBody(); - - if (log.isDebugEnabled()) { - log.debug("Response Body:\n{}", responseBody); - } - - return GrantedTokenInfo.builder() - .accessToken(responseBody.getAccessToken()) - .refreshToken(responseBody.getRefreshToken()) - .id(extractIdFromIdToken(responseBody.getIdToken())) - .authVendor(AuthVendor.KAKAO) - .build(); - } - - private RequestEntity> buildTokenRequestEntity(String code, String redirectUri) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add(TOKEN_REQUEST_HEADER_KEY_GRANT_TYPE, TOKEN_REQUEST_HEADER_VALUE_GRANT_TYPE); - params.add(TOKEN_REQUEST_HEADER_KEY_CLIENT_ID, this.kakaoClientId); - if (this.kakaoClientSecret != null) { - params.add(TOKEN_REQUEST_HEADER_KEY_CLIENT_SECRET, this.kakaoClientSecret); - } - params.add(TOKEN_REQUEST_HEADER_KEY_REDIRECT_URI, redirectUri); - params.add(TOKEN_REQUEST_HEADER_KEY_CODE, code); -// params.add("client_secret", this.kakaoClientSecret); // TODO: 카카오 로그인 Client Secret 설정 - - return RequestEntity.post(KAKAO_TOKEN_REQUEST_URL) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .body(params); - } - - private String extractIdFromIdToken(String idToken) { - // 참고: https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc-id-token - if (log.isDebugEnabled()) { - log.debug("idToken={}", idToken); - } - String payload = idToken.split("\\.")[1]; - byte[] decoded = Base64.getDecoder().decode(payload); - char[] chars = new char[decoded.length]; - for (int i = 0; i < chars.length; i++) { - chars[i] = (char) decoded[i]; - } - return new JSONObject(String.valueOf(chars)).getString("sub"); - } - - @Override - public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { - RequestEntity requestEntity = RequestEntity.get(KAKAO_OIDC_USER_INFO_URL) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .build(); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, String.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new InvalidAccessTokenException("Access token not valid"); - } - - JSONObject responseBody = new JSONObject(responseEntity.getBody()); - return OAuth2AuthenticationInfo.builder() - .id(responseBody.getString("sub")) - .build(); - } - - @Override - public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { - RequestEntity requestEntity = RequestEntity.get(KAKAO_ME_URL) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .build(); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, String.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new InvalidAccessTokenException("Access Token is not valid"); - } - - JSONObject responseBody = new JSONObject(responseEntity.getBody()); - JSONObject kakaoAccount = responseBody.getJSONObject("kakao_account"); - return OAuth2UserInfo.builder() - .id(String.valueOf(responseBody.getLong("id"))) - .name(kakaoAccount.isNull("name") ? null : kakaoAccount.getString("name")) - .profileImageUrl( - kakaoAccount.isNull("profile") || kakaoAccount.getJSONObject("profile").isNull("rofile_image_url") - ? null - : kakaoAccount.getJSONObject("profile").getString("profile_image_url") - ) - .build(); - } - - @Override - public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { - MultiValueMap bodyParams = new LinkedMultiValueMap<>(); - bodyParams.add("grant_type", "refresh_token"); - bodyParams.add("client_id", this.kakaoClientId); - bodyParams.add("refresh_token", refreshToken); -// bodyParams.add("client_secret", this.kakaoClientSecret); // TODO: 카카오 로그인 Client Secret 설정 - - RequestEntity> requestEntity = RequestEntity.post(KAKAO_TOKEN_REQUEST_URL) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(bodyParams); - - ResponseEntity responseEntity = - this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); - - if (responseEntity.getStatusCode().is4xxClientError()) { - throw new InvalidRefreshTokenException("Refresh token not valid"); - } - - OAuth2GrantedToken responseBody = responseEntity.getBody(); - - return GrantedTokenInfo.builder() - .accessToken(responseBody.getAccessToken()) - .refreshToken(responseBody.getRefreshToken()) - .id(extractIdFromIdToken(responseBody.getIdToken())) - .authVendor(AuthVendor.KAKAO) - .build(); - } - - @Override - public AuthVendor getAuthVendor() { - return AuthVendor.KAKAO; - } -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.dto.external.OAuth2GrantedToken; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +//import lombok.extern.slf4j.Slf4j; +//import org.json.JSONObject; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.context.annotation.Profile; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.MediaType; +//import org.springframework.http.RequestEntity; +//import org.springframework.http.ResponseEntity; +//import org.springframework.stereotype.Component; +//import org.springframework.util.LinkedMultiValueMap; +//import org.springframework.util.MultiValueMap; +//import org.springframework.web.client.RestTemplate; +// +//import java.util.Base64; +// +//@Component +//@Profile("qa.test | prod") +//@Slf4j +//public class RestTemplateKakaoTokenProcessor implements TokenProcessor { +// private static final String KAKAO_TOKEN_REQUEST_URL = "https://kauth.kakao.com/oauth/token"; +// private static final String KAKAO_OIDC_USER_INFO_URL = "https://kapi.kakao.com/v1/oidc/userinfo"; +// private static final String KAKAO_ME_URL = "https://kapi.kakao.com/v2/user/me"; +// private static final String TOKEN_REQUEST_HEADER_KEY_GRANT_TYPE = "grant_type"; +// private static final String TOKEN_REQUEST_HEADER_VALUE_GRANT_TYPE = "authorization_code"; +// private static final String TOKEN_REQUEST_HEADER_KEY_CLIENT_ID = "client_id"; +// private static final String TOKEN_REQUEST_HEADER_KEY_CLIENT_SECRET = "client_secret"; +// private static final String TOKEN_REQUEST_HEADER_KEY_REDIRECT_URI = "redirect_uri"; +// private static final String TOKEN_REQUEST_HEADER_KEY_CODE = "code"; +// +// private final RestTemplate restTemplate; +// private final String kakaoClientId; +// private final String kakaoClientSecret; // Nullable +// +// public RestTemplateKakaoTokenProcessor(RestTemplate restTemplate, +// @Value("${oauth2.kakao.client-id}") String kakaoClientId, +// @Value("${oauth2.kakao.client-secret}") String kakaoClientSecret) { +// this.restTemplate = restTemplate; +// this.kakaoClientId = kakaoClientId; +// this.kakaoClientSecret = "empty".equals(kakaoClientSecret) ? null : kakaoClientSecret; +// } +// +// @Override +// public GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException { +// log.debug("redirect uri: {}", redirectUri); +// +// RequestEntity> requestEntity = buildTokenRequestEntity(code, redirectUri); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new UnsupportedCodeException("Code is not supported."); +// } +// +// OAuth2GrantedToken responseBody = responseEntity.getBody(); +// +// if (log.isDebugEnabled()) { +// log.debug("Response Body:\n{}", responseBody); +// } +// +// return GrantedTokenInfo.builder() +// .accessToken(responseBody.getAccessToken()) +// .refreshToken(responseBody.getRefreshToken()) +// .id(extractIdFromIdToken(responseBody.getIdToken())) +// .authVendor(AuthVendor.KAKAO) +// .build(); +// } +// +// private RequestEntity> buildTokenRequestEntity(String code, String redirectUri) { +// MultiValueMap params = new LinkedMultiValueMap<>(); +// params.add(TOKEN_REQUEST_HEADER_KEY_GRANT_TYPE, TOKEN_REQUEST_HEADER_VALUE_GRANT_TYPE); +// params.add(TOKEN_REQUEST_HEADER_KEY_CLIENT_ID, this.kakaoClientId); +// if (this.kakaoClientSecret != null) { +// params.add(TOKEN_REQUEST_HEADER_KEY_CLIENT_SECRET, this.kakaoClientSecret); +// } +// params.add(TOKEN_REQUEST_HEADER_KEY_REDIRECT_URI, redirectUri); +// params.add(TOKEN_REQUEST_HEADER_KEY_CODE, code); +//// params.add("client_secret", this.kakaoClientSecret); // TODO: 카카오 로그인 Client Secret 설정 +// +// return RequestEntity.post(KAKAO_TOKEN_REQUEST_URL) +// .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) +// .body(params); +// } +// +// private String extractIdFromIdToken(String idToken) { +// // 참고: https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc-id-token +// if (log.isDebugEnabled()) { +// log.debug("idToken={}", idToken); +// } +// String payload = idToken.split("\\.")[1]; +// byte[] decoded = Base64.getDecoder().decode(payload); +// char[] chars = new char[decoded.length]; +// for (int i = 0; i < chars.length; i++) { +// chars[i] = (char) decoded[i]; +// } +// return new JSONObject(String.valueOf(chars)).getString("sub"); +// } +// +// @Override +// public OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException { +// RequestEntity requestEntity = RequestEntity.get(KAKAO_OIDC_USER_INFO_URL) +// .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) +// .build(); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, String.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new InvalidAccessTokenException("Access token not valid"); +// } +// +// JSONObject responseBody = new JSONObject(responseEntity.getBody()); +// return OAuth2AuthenticationInfo.builder() +// .id(responseBody.getString("sub")) +// .build(); +// } +// +// @Override +// public OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException { +// RequestEntity requestEntity = RequestEntity.get(KAKAO_ME_URL) +// .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) +// .build(); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, String.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new InvalidAccessTokenException("Access Token is not valid"); +// } +// +// JSONObject responseBody = new JSONObject(responseEntity.getBody()); +// JSONObject kakaoAccount = responseBody.getJSONObject("kakao_account"); +// return OAuth2UserInfo.builder() +// .id(String.valueOf(responseBody.getLong("id"))) +// .name(kakaoAccount.isNull("name") ? null : kakaoAccount.getString("name")) +// .profileImageUrl( +// kakaoAccount.isNull("profile") || kakaoAccount.getJSONObject("profile").isNull("rofile_image_url") +// ? null +// : kakaoAccount.getJSONObject("profile").getString("profile_image_url") +// ) +// .build(); +// } +// +// @Override +// public GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException { +// MultiValueMap bodyParams = new LinkedMultiValueMap<>(); +// bodyParams.add("grant_type", "refresh_token"); +// bodyParams.add("client_id", this.kakaoClientId); +// bodyParams.add("refresh_token", refreshToken); +//// bodyParams.add("client_secret", this.kakaoClientSecret); // TODO: 카카오 로그인 Client Secret 설정 +// +// RequestEntity> requestEntity = RequestEntity.post(KAKAO_TOKEN_REQUEST_URL) +// .contentType(MediaType.APPLICATION_FORM_URLENCODED) +// .body(bodyParams); +// +// ResponseEntity responseEntity = +// this.restTemplate.exchange(requestEntity, OAuth2GrantedToken.class); +// +// if (responseEntity.getStatusCode().is4xxClientError()) { +// throw new InvalidRefreshTokenException("Refresh token not valid"); +// } +// +// OAuth2GrantedToken responseBody = responseEntity.getBody(); +// +// return GrantedTokenInfo.builder() +// .accessToken(responseBody.getAccessToken()) +// .refreshToken(responseBody.getRefreshToken()) +// .id(extractIdFromIdToken(responseBody.getIdToken())) +// .authVendor(AuthVendor.KAKAO) +// .build(); +// } +// +// @Override +// public AuthVendor getAuthVendor() { +// return AuthVendor.KAKAO; +// } +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenProcessor.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenProcessor.java index 56e8580..c303eb8 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenProcessor.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenProcessor.java @@ -1,31 +1,31 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; - -public interface TokenProcessor { - - /** - * Authorization Server로부터 토큰을 발급받는다. Access token은 필수이며, Refresh token은 - * 선택적으로 발급받을 수 있다. - * - * @param code 토큰을 발급받기 위한 인가코드 - * @param redirectUri 리다이렉트 URI - * @return 토큰 정보를 가지고 있는 DTO 객체. 토큰 발행 주체 권한 설정에 따라 refreshToken과 - * refreshTokenExpirationnull일 수 있다. - */ - GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException; - - OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException; - - OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException; - - GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException; - - AuthVendor getAuthVendor(); -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +// +//public interface TokenProcessor { +// +// /** +// * Authorization Server로부터 토큰을 발급받는다. Access token은 필수이며, Refresh token은 +// * 선택적으로 발급받을 수 있다. +// * +// * @param code 토큰을 발급받기 위한 인가코드 +// * @param redirectUri 리다이렉트 URI +// * @return 토큰 정보를 가지고 있는 DTO 객체. 토큰 발행 주체 권한 설정에 따라 refreshToken과 +// * refreshTokenExpirationnull일 수 있다. +// */ +// GrantedTokenInfo grantToken(String code, String redirectUri) throws UnsupportedCodeException; +// +// OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException; +// +// OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException; +// +// GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException; +// +// AuthVendor getAuthVendor(); +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenSupport.java b/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenSupport.java index 3ab6a71..b817f4a 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenSupport.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/support/TokenSupport.java @@ -1,42 +1,42 @@ -package com.codezerotoone.mvp.global.security.token.support; - -import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; -import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; -import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; -import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; -import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; -import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; - -/** - *

토큰과 관련된 작업을 수행하는 메소드를 정의한 인터페이스.

- *

이 인터페이스를 구현한 객체는 다음과 같은 기능을 수행해야 한다.

- *
    - *
  1. Access Token 및 Refresh Token 발급
  2. - *
  3. Access Token 및 Refresh Token 검증
  4. - *
  5. Refresh Token을 사용하여 Access Token 재발급
  6. - *
  7. 소셜 로그인 서비스로부터 사용자의 이름, 이메일 등 정보 가져와서 반환
  8. - *
- * - * @author PGD - */ -public interface TokenSupport { - - /** - * Authorization Server로부터 토큰을 발급받는다. Access token은 필수이며, Refresh token은 - * 선택적으로 발급받을 수 있다. - * - * @param code 토큰을 발급받기 위한 인가코드 - * @param redirectUri 리다이렉트 URI - * @param authVendor code를 발급한 인증 서버 - * @return 토큰 정보를 가지고 있는 DTO 객체. 토큰 발행 주체 권한 설정에 따라 refreshToken과 - * refreshTokenExpirationnull일 수 있다. - */ - GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException; - - OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException; - - OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException; - - GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException; -} +//package com.codezerotoone.mvp.global.security.token.support; +// +//import com.codezerotoone.mvp.global.security.token.dto.GrantedTokenInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2AuthenticationInfo; +//import com.codezerotoone.mvp.global.security.token.dto.OAuth2UserInfo; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidAccessTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.InvalidRefreshTokenException; +//import com.codezerotoone.mvp.global.security.token.exception.UnsupportedCodeException; +//import com.codezerotoone.mvp.global.security.token.vendor.AuthVendor; +// +///** +// *

토큰과 관련된 작업을 수행하는 메소드를 정의한 인터페이스.

+// *

이 인터페이스를 구현한 객체는 다음과 같은 기능을 수행해야 한다.

+// *
    +// *
  1. Access Token 및 Refresh Token 발급
  2. +// *
  3. Access Token 및 Refresh Token 검증
  4. +// *
  5. Refresh Token을 사용하여 Access Token 재발급
  6. +// *
  7. 소셜 로그인 서비스로부터 사용자의 이름, 이메일 등 정보 가져와서 반환
  8. +// *
+// * +// * @author PGD +// */ +//public interface TokenSupport { +// +// /** +// * Authorization Server로부터 토큰을 발급받는다. Access token은 필수이며, Refresh token은 +// * 선택적으로 발급받을 수 있다. +// * +// * @param code 토큰을 발급받기 위한 인가코드 +// * @param redirectUri 리다이렉트 URI +// * @param authVendor code를 발급한 인증 서버 +// * @return 토큰 정보를 가지고 있는 DTO 객체. 토큰 발행 주체 권한 설정에 따라 refreshToken과 +// * refreshTokenExpirationnull일 수 있다. +// */ +// GrantedTokenInfo grantToken(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException; +// +// OAuth2AuthenticationInfo authenticate(String accessToken) throws InvalidAccessTokenException; +// +// OAuth2UserInfo retrieveUserInfo(String accessToken) throws InvalidAccessTokenException; +// +// GrantedTokenInfo refreshToken(String refreshToken) throws InvalidRefreshTokenException; +//} diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/vendor/AuthSocial.java b/src/main/java/com/codezerotoone/mvp/global/security/token/vendor/AuthSocial.java new file mode 100644 index 0000000..e590a52 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/vendor/AuthSocial.java @@ -0,0 +1,41 @@ +package com.codezerotoone.mvp.global.security.token.vendor; + +import com.codezerotoone.mvp.global.security.token.support.GoogleResponse; +import com.codezerotoone.mvp.global.security.token.support.KakaoResponse; +import com.codezerotoone.mvp.global.security.token.support.OAuth2Response; +import java.util.Arrays; +import java.util.Map; + +public enum AuthSocial { + GOOGLE("google") { + @Override + public OAuth2Response createResponse(Map attributes) { + return new GoogleResponse(attributes); + } + }, + KAKAO("kakao") { + @Override + public OAuth2Response createResponse(Map attributes) { + return new KakaoResponse(attributes); + } + }; + + private final String name; + + AuthSocial(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public abstract OAuth2Response createResponse(Map attributes); + + public static AuthSocial fromName(String name) { + return Arrays.stream(AuthSocial.values()) + .filter(social -> social.name.equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 소셜 로그인 타입입니다: " + name)); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/util/CookieUtil.java b/src/main/java/com/codezerotoone/mvp/global/util/CookieUtil.java new file mode 100644 index 0000000..c14d849 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/util/CookieUtil.java @@ -0,0 +1,37 @@ +package com.codezerotoone.mvp.global.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public String createCookie(String key, String value) { + return ResponseCookie.from(key, value) + .path("/") + .maxAge(60 * 60 * 24 * 30) + .httpOnly(true) + .secure(true) + .sameSite("None") + .build() + .toString(); + } + + public String getCookieValue(String cookieName, HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (cookie.getName().equals(cookieName)) { + return cookie.getValue(); + } + } + + return null; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/util/JwtUtil.java b/src/main/java/com/codezerotoone/mvp/global/util/JwtUtil.java new file mode 100644 index 0000000..35e2be8 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/util/JwtUtil.java @@ -0,0 +1,59 @@ +package com.codezerotoone.mvp.global.util; + +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + private SecretKey secretKey; + + public JwtUtil(@Value("${spring.jwt.secret}") String secret) { + + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() + .get("role", String.class); + } + + public Long getMemberId(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload() + .get("memberId", Long.class); + } + + public Boolean isExpired(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration() + .before(new Date()); + } + + public String createAccessToken(Long memberId, String role) { + return Jwts.builder() + .claim("memberId", memberId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000L)) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(Long memberId, String role) { + return Jwts.builder() + .claim("memberId", memberId) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 60 * 60 * 10000L)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/resources/application-qa.test.yml b/src/main/resources/application-qa.test.yml index 3411c07..aa859e8 100644 --- a/src/main/resources/application-qa.test.yml +++ b/src/main/resources/application-qa.test.yml @@ -5,6 +5,44 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.MariaDBDialect + jwt: + secret: vjldksjfoiejfoaiejflskfjlsdkfjsoeifjdfkdfjlekdfjdkfjei + + security: + oauth2: + client: + + registration: + kakao: + client-name: kakao + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: http://localhost:8080/login/oauth2/code/kakao + authorization-grant-type: authorization_code + scope: profile_nickname, profile_image + client-authentication-method: client_secret_post + + google: + client-name: google + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: http://localhost:8080/login/oauth2/code/google + authorization-grant-type: authorization_code + scope: profile, email + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: properties + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://openidconnect.googleapis.com/v1/userinfo + user-name-attribute: sub + + logging: level: @@ -38,10 +76,11 @@ info: app: environment: QA환경 -oauth2: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET:empty} # QA 서버의 경우 CLIENT_SECRET이 없음 +#oauth2: +# google: +# client-id: ${GOOGLE_CLIENT_ID} +# client-secret: ${GOOGLE_CLIENT_SECRET} +# kakao: +# client-id: ${KAKAO_CLIENT_ID} +# client-secret: ${KAKAO_CLIENT_SECRET:empty} # QA 서버의 경우 CLIENT_SECRET이 없음 + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce58447..6f7ce52 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + profiles: + active: qa.test application: name: mvp @@ -56,4 +58,6 @@ resources: # Logging Levels logging: level: - org.springframework.security: error + org.springframework.security: debug + org.springframework.security.oauth2.client: TRACE + org.springframework.security.oauth2.core: TRACE \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..ce99921 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,55 @@ + + + + + + Social Login + + + +
+ +