Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ DB_PASSWORD=
# Auth
JWT_SECRET=

# OAuth credential encryption
CREDENTIAL_ENCRYPT_KEY=

# OIDC
APPLE_CLIENT_ID=com.Routee-iOS

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
export DB_USERNAME="${{ secrets.DB_USERNAME }}"
export DB_PASSWORD="${{ secrets.DB_PASSWORD }}"
export JWT_SECRET="${{ secrets.JWT_SECRET }}"
export CREDENTIAL_ENCRYPT_KEY="${{ secrets.CREDENTIAL_ENCRYPT_KEY }}"
export DOZZLE_USERNAME="${{ secrets.DOZZLE_USERNAME }}"
export DOZZLE_PASSWORD="${{ secrets.DOZZLE_PASSWORD }}"
export GRAFANA_ADMIN_USER="${{ secrets.GRAFANA_ADMIN_USER }}"
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ services:
SPRING_DATASOURCE_USERNAME: ${DB_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
CREDENTIAL_ENCRYPT_KEY: ${CREDENTIAL_ENCRYPT_KEY}
S3_REGION: ${S3_REGION}
S3_BUCKET: ${S3_BUCKET}
S3_ENDPOINT: ${S3_ENDPOINT}
Expand Down Expand Up @@ -59,6 +60,7 @@ services:
SPRING_DATASOURCE_USERNAME: ${DB_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
CREDENTIAL_ENCRYPT_KEY: ${CREDENTIAL_ENCRYPT_KEY}
S3_REGION: ${S3_REGION}
S3_BUCKET: ${S3_BUCKET}
S3_ENDPOINT: ${S3_ENDPOINT}
Expand Down
4 changes: 4 additions & 0 deletions routee-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ oauth:
key-id: ${APPLE_KEY_ID}
private-key: ${APPLE_PRIVATE_KEY}

member:
oauth-credential:
encryption-key: ${CREDENTIAL_ENCRYPT_KEY}

jwt:
secret: ${JWT_SECRET}
issuer: org.routee
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.sopt.routee.auth.internal.controller;

import org.sopt.routee.auth.internal.service.AuthService;
import org.sopt.routee.auth.internal.service.dto.command.LoginCommand;
import org.sopt.routee.auth.internal.code.SuccessCode;
import org.sopt.routee.auth.internal.controller.dto.response.TokenResponse;
import org.sopt.routee.auth.internal.controller.dto.request.LoginRequest;
Expand Down Expand Up @@ -36,7 +35,7 @@ public class AuthController implements AuthControllerDocs {
public ResponseEntity<SuccessResponse<TokenResponse>> login(
@Valid @RequestBody LoginRequest request
) {
TokenResult result = authService.login(new LoginCommand(request.provider(), request.idToken()));
TokenResult result = authService.login(request.toCommand());
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.success(SuccessCode.LOGIN_SUCCESS, TokenResponse.of(result)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
@Tag(name = "Auth", description = "인증 API")
public interface AuthControllerDocs {

@Operation(summary = "소셜 로그인", description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다.")
@Operation(summary = "소셜 로그인",
description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다. Apple 로그인 회원 중 아직 저장된 Apple refresh_token이 없는 "
+ "회원은 authorization_code가 필수이며, 이를 교환해 refresh_token을 저장합니다. 이미 저장되어 있는 회원은 authorization_code를 "
+ "전달하지 않아도 되고 전달되어도 무시됩니다. 저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다. "
+ "저장된 refresh_token이 없는데 authorization_code를 전달하지 않았거나, authorization_code 교환/저장에 실패하면 로그인 자체가 실패합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그인 성공",
content = @Content(schema = @Schema(implementation = TokenResponse.class))),
Expand All @@ -32,24 +36,42 @@ public interface AuthControllerDocs {
@ExampleObject(name = "INVALID_INPUT_VALUE",
value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"provider는 필수입니다.\"}"),
@ExampleObject(name = "INVALID_REQUEST_BODY",
value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}")
value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}"),
@ExampleObject(name = "AUTHORIZATION_CODE_REQUIRED",
value = "{\"status\":400,\"code\":\"AUTHORIZATION_CODE_REQUIRED\",\"message\":\"저장된 소셜 로그인 연동 정보가 없어 authorization_code가 필요합니다.\"}")
})),
@ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 id_token",
@ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 id_token/authorization_code",
content = @Content(schema = @Schema(implementation = FailureResponse.class),
examples = {
@ExampleObject(name = "INVALID_ID_TOKEN",
value = "{\"status\":401,\"code\":\"INVALID_ID_TOKEN\",\"message\":\"유효하지 않은 id_token입니다.\"}"),
@ExampleObject(name = "ID_TOKEN_EXPIRED",
value = "{\"status\":401,\"code\":\"ID_TOKEN_EXPIRED\",\"message\":\"만료된 id_token입니다.\"}"),
@ExampleObject(name = "INVALID_TOKEN_CLAIMS",
value = "{\"status\":401,\"code\":\"INVALID_TOKEN_CLAIMS\",\"message\":\"id_token 클레임이 유효하지 않습니다.\"}")
value = "{\"status\":401,\"code\":\"INVALID_TOKEN_CLAIMS\",\"message\":\"id_token 클레임이 유효하지 않습니다.\"}"),
@ExampleObject(name = "AUTHORIZATION_CODE_EXPIRED",
value = "{\"status\":401,\"code\":\"AUTHORIZATION_CODE_EXPIRED\",\"message\":\"만료되었거나 유효하지 않은 authorization_code입니다.\"}")
})),
@ApiResponse(responseCode = "404", description = "가입된 회원 없음 - 회원가입 필요",
content = @Content(schema = @Schema(implementation = FailureResponse.class),
examples = @ExampleObject(name = "MEMBER_NOT_FOUND",
value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}")))
value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))),
@ApiResponse(responseCode = "502", description = "소셜 로그인 refresh_token 교환에 실패함 (저장된 연동 정보가 없는 회원만 해당)",
content = @Content(schema = @Schema(implementation = FailureResponse.class),
examples = @ExampleObject(name = "OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED",
value = "{\"status\":502,\"code\":\"OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED\",\"message\":\"소셜 로그인 refresh_token 교환에 실패했습니다.\"}")))
})
ResponseEntity<SuccessResponse<TokenResponse>> login(@Valid @RequestBody LoginRequest request);
ResponseEntity<SuccessResponse<TokenResponse>> login(
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
content = @Content(schema = @Schema(implementation = LoginRequest.class),
examples = {
@ExampleObject(name = "APPLE_MEMBER", summary = "Apple 로그인 회원",
value = "{\"provider\":\"APPLE\",\"idToken\":\"eyJ...\",\"authorizationCode\":\"c1234...\"}"),
@ExampleObject(name = "OTHER_MEMBER", summary = "그 외 소셜 로그인 회원",
value = "{\"provider\":\"GOOGLE\",\"idToken\":\"eyJ...\"}")
}))
@Valid @RequestBody LoginRequest request
);

@Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 액세스/리프레시 토큰을 재발급합니다.")
@ApiResponses({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.sopt.routee.auth.internal.service.dto.command.LoginCommand;
import org.sopt.routee.external.api.type.OAuthProvider;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

Expand All @@ -11,9 +12,14 @@ public record LoginRequest(
OAuthProvider provider,

@NotBlank(message = "id_token은 필수입니다.")
String idToken
String idToken,

@Schema(description = "Apple 로그인 시 함께 전달받은 인가 코드. Apple refresh_token 발급에 사용됩니다. "
+ "아직 저장된 Apple refresh_token이 없는 회원은 필수이며, 없으면 로그인이 실패합니다. "
+ "이미 발급받아 저장된 회원이라면 전달하지 않아도 되고 전달되어도 무시됩니다. Apple 외 소셜 로그인 회원은 필요하지 않습니다.")
String authorizationCode
) {
public LoginCommand toCommand() {
return new LoginCommand(provider, idToken);
return new LoginCommand(provider, idToken, authorizationCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ public class AuthService {
public TokenResult login(LoginCommand command) {
String oauthId = oidcVerifyPort.extractSubject(command.provider(), command.idToken());

TokenClaimsResult tokenClaims = memberUseCase.getTokenResult(oauthId, command.provider());
TokenClaimsResult tokenClaims =
memberUseCase.getTokenResult(oauthId, command.provider(), command.authorizationCode());

TokenResult tokenResult = issueTokenPair(tokenClaims.memberId(), tokenClaims.memberRole());
log.info("Login succeeded. memberId={}, provider={}", tokenClaims.memberId(), command.provider());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

public record LoginCommand(
OAuthProvider provider,
String idToken
String idToken,
String authorizationCode
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.sopt.routee.external.api.port;

import org.sopt.routee.external.api.type.OAuthProvider;

public interface OAuthRefreshTokenExchangePort {

String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

public interface OAuthRevokePort {

void revoke(String authorizationCode);
void revoke(String refreshToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.sopt.routee.external.internal.oauth.adapter;

import com.fasterxml.jackson.annotation.JsonProperty;

record AppleOAuthErrorResponse(
@JsonProperty("error") String error
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.sopt.routee.external.internal.oauth.adapter;

import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
class AppleOAuthFormClient {

private final RestClient restClient;
private final OAuthRevokeProperty property;
private final AppleClientSecretGenerator clientSecretGenerator;

MultiValueMap<String, String> credentialForm() {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();

form.add("client_id", property.clientId());
form.add("client_secret", clientSecretGenerator.generate());

return form;
}

<T> T post(String uri, MultiValueMap<String, String> form, Class<T> responseType) {
return restClient.post()
.uri(uri)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(responseType);
}

String tokenUri() {
return property.tokenUri();
}

String revokeUri() {
return property.revokeUri();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package org.sopt.routee.external.internal.oauth.adapter;

import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort;
import org.sopt.routee.external.api.type.OAuthProvider;
import org.sopt.routee.external.internal.oauth.exception.OAuthAuthorizationCodeExpiredException;
import org.sopt.routee.external.internal.oauth.exception.OAuthRefreshTokenExchangeException;
import org.sopt.routee.external.internal.oidc.exception.UnsupportedOidcProviderException;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClientException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
class AppleOAuthRefreshTokenExchangeAdapter implements OAuthRefreshTokenExchangePort {

private static final String INVALID_GRANT = "invalid_grant";

private final AppleOAuthFormClient client;
private final ObjectMapper objectMapper;

@Override
public String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode) {
if (provider != OAuthProvider.APPLE) {
throw new UnsupportedOidcProviderException();
}

MultiValueMap<String, String> form = client.credentialForm();

form.add("grant_type", "authorization_code");
form.add("code", authorizationCode);

try {
OAuthTokenResponse response = client.post(client.tokenUri(), form, OAuthTokenResponse.class);

if (response == null || !StringUtils.hasText(response.refreshToken())) {
throw new OAuthRefreshTokenExchangeException();
}

return response.refreshToken();
} catch (HttpClientErrorException e) {
if (isInvalidGrant(e)) {
throw new OAuthAuthorizationCodeExpiredException(e);
}
throw new OAuthRefreshTokenExchangeException(e);
} catch (RestClientException e) {
throw new OAuthRefreshTokenExchangeException(e);
}
}

private boolean isInvalidGrant(HttpClientErrorException e) {
try {
AppleOAuthErrorResponse errorResponse =
objectMapper.readValue(e.getResponseBodyAsString(), AppleOAuthErrorResponse.class);
return INVALID_GRANT.equals(errorResponse.error());
} catch (JacksonException parseException) {
return false;
}
}
}
Loading
Loading