Skip to content

Commit

Permalink
v1.1.0 (#238)
Browse files Browse the repository at this point in the history
  • Loading branch information
kdomo authored Jan 28, 2024
2 parents 5de8f6d + 732d8e2 commit fb4afe1
Show file tree
Hide file tree
Showing 82 changed files with 2,419 additions and 565 deletions.
19 changes: 11 additions & 8 deletions .github/workflows/develop_build_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@ jobs:
tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ steps.image-tag.outputs.value }}

# 서버로 docker-compose 파일 전송
- name: Copy docker-compose.yml to NCP Server
uses: appleboy/scp-action@v0.1.4
- name: copy source via ssh key
uses: burnett01/rsync-deployments@4.1
with:
host: ${{ secrets.NCP_HOST }}
username: ${{ secrets.NCP_USERNAME }}
key: ${{ secrets.NCP_PRIVATE_KEY }}
port: ${{ secrets.NCP_PORT }}
source: docker-compose.yaml
target: /home/tenminute/
switches: -avzr --delete
remote_host: ${{ secrets.NCP_HOST }}
remote_user: ${{ secrets.NCP_USERNAME }}
remote_port: ${{ secrets.NCP_PORT }}
remote_key: ${{ secrets.NCP_PRIVATE_KEY }}
path: docker-compose.yaml
remote_path: /home/tenminute/

# 슬랙으로 빌드 스캔 결과 전송
- name: Send to slack
Expand Down Expand Up @@ -107,5 +108,7 @@ jobs:
script: |
echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}"
docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ env.NCP_IMAGE_TAG }}
SWAGGER_VERSION=${{ env.NCP_IMAGE_TAG }}
sed -i "s/SWAGGER_VERSION=.*/SWAGGER_VERSION=$SWAGGER_VERSION/" .env
docker compose -f /home/tenminute/docker-compose.yaml up -d
docker image prune -a -f
2 changes: 2 additions & 0 deletions .github/workflows/develop_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ jobs:
script: |
echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}"
docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ github.event.inputs.commit_hash }}
SWAGGER_VERSION=${{ env.NCP_IMAGE_TAG }}
sed -i "s/SWAGGER_VERSION=.*/SWAGGER_VERSION=$SWAGGER_VERSION/" .env
docker compose -f /home/tenminute/docker-compose.yaml up -d
docker image prune -a -f
19 changes: 11 additions & 8 deletions .github/workflows/production_build_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,16 @@ jobs:
tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ steps.image-tag.outputs.value }}

# 서버로 docker-compose 파일 전송
- name: Copy docker-compose.yml to NCP Server
uses: appleboy/scp-action@v0.1.4
- name: copy source via ssh key
uses: burnett01/rsync-deployments@4.1
with:
host: ${{ secrets.NCP_HOST }}
username: ${{ secrets.NCP_USERNAME }}
key: ${{ secrets.NCP_PRIVATE_KEY }}
port: ${{ secrets.NCP_PORT }}
source: docker-compose.yaml
target: /home/tenminute/
switches: -avzr --delete
remote_host: ${{ secrets.NCP_HOST }}
remote_user: ${{ secrets.NCP_USERNAME }}
remote_port: ${{ secrets.NCP_PORT }}
remote_key: ${{ secrets.NCP_PRIVATE_KEY }}
path: docker-compose.yaml
remote_path: /home/tenminute/

# 슬랙으로 빌드 스캔 결과 전송
- name: Send to slack
Expand Down Expand Up @@ -108,5 +109,7 @@ jobs:
script: |
echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}"
docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ env.NCP_IMAGE_TAG }}
SWAGGER_VERSION=${{ env.NCP_IMAGE_TAG }}
sed -i "s/SWAGGER_VERSION=.*/SWAGGER_VERSION=$SWAGGER_VERSION/" .env
docker compose -f /home/tenminute/docker-compose.yaml up -d
docker image prune -a -f
4 changes: 3 additions & 1 deletion .github/workflows/production_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ jobs:
script: |
echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}"
docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ github.event.inputs.version }}
SWAGGER_VERSION=${{ env.NCP_IMAGE_TAG }}
sed -i "s/SWAGGER_VERSION=.*/SWAGGER_VERSION=$SWAGGER_VERSION/" .env
docker compose -f /home/tenminute/docker-compose.yaml up -d
docker image prune -a -f
docker image prune -a -f
25 changes: 21 additions & 4 deletions src/main/java/com/depromeet/domain/auth/api/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.depromeet.domain.auth.api;

import com.depromeet.domain.auth.application.AuthService;
import com.depromeet.domain.auth.domain.OauthProvider;
import com.depromeet.domain.auth.dto.request.IdTokenRequest;
import com.depromeet.domain.auth.dto.request.MemberRegisterRequest;
import com.depromeet.domain.auth.dto.request.UsernamePasswordRequest;
import com.depromeet.domain.auth.dto.response.SocialLoginResponse;
import com.depromeet.domain.auth.dto.response.TokenPairResponse;
import com.depromeet.global.util.CookieUtil;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -12,10 +15,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@Tag(name = "1-1. [인증]", description = "인증 관련 API")
@RestController
Expand Down Expand Up @@ -58,4 +58,21 @@ public ResponseEntity<TokenPairResponse> memberLogin(

return ResponseEntity.ok().headers(tokenHeaders).body(response);
}

@Operation(
summary = "소셜 로그인",
description = "소셜 로그인 후 토큰을 발급합니다. 가입하지 않은 유저인 경우 임시 회원가입을 진행합니다.")
@PostMapping("/social-login")
public ResponseEntity<SocialLoginResponse> memberSocialLogin(
@RequestParam(name = "provider") OauthProvider provider,
@Valid @RequestBody IdTokenRequest request) {

SocialLoginResponse response = authService.socialLoginMember(request, provider);

String accessToken = response.accessToken();
String refreshToken = response.refreshToken();
HttpHeaders tokenHeaders = cookieUtil.generateTokenCookies(accessToken, refreshToken);

return ResponseEntity.ok().headers(tokenHeaders).body(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package com.depromeet.domain.auth.application;

import com.depromeet.domain.auth.application.nickname.NicknameGenerationStrategy;
import com.depromeet.domain.auth.domain.OauthProvider;
import com.depromeet.domain.auth.dto.request.IdTokenRequest;
import com.depromeet.domain.auth.dto.request.MemberRegisterRequest;
import com.depromeet.domain.auth.dto.request.UsernamePasswordRequest;
import com.depromeet.domain.auth.dto.response.SocialLoginResponse;
import com.depromeet.domain.auth.dto.response.TokenPairResponse;
import com.depromeet.domain.member.dao.MemberRepository;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.member.domain.MemberRole;
import com.depromeet.domain.member.domain.MemberStatus;
import com.depromeet.domain.member.domain.OauthInfo;
import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import com.depromeet.global.util.MemberUtil;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -27,6 +33,8 @@ public class AuthService {
private final MemberUtil memberUtil;
private final PasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
private final IdTokenVerifier idTokenVerifier;
private final NicknameGenerationStrategy nicknameGenerationStrategy;

public void registerMember(MemberRegisterRequest request) {
final Member member = memberUtil.getCurrentMember();
Expand Down Expand Up @@ -90,4 +98,43 @@ private TokenPairResponse getLoginResponse(Member member) {

return TokenPairResponse.from(accessToken, refreshToken);
}

public SocialLoginResponse socialLoginMember(IdTokenRequest request, OauthProvider provider) {
OidcUser oidcUser = idTokenVerifier.getOidcUser(request.idToken(), provider);
Member member = fetchOrCreate(oidcUser);
member.updateLastLoginAt();

TokenPairResponse loginResponse = getLoginResponse(member);
boolean isGuest = member.getRole() == MemberRole.GUEST;

return SocialLoginResponse.from(loginResponse, isGuest);
}

private Member fetchOrCreate(OidcUser oidcUser) {
return memberRepository
.findByOauthInfo(extractOauthInfo(oidcUser))
.orElseGet(() -> saveAsGuest(oidcUser));
}

private Member saveAsGuest(OidcUser oidcUser) {

OauthInfo oauthInfo = extractOauthInfo(oidcUser);
String nickname = generateRandomNickname();
Member guest = Member.createGuestMember(oauthInfo, nickname);
return memberRepository.save(guest);
}

private String generateRandomNickname() {
while (true) {
String nickname = nicknameGenerationStrategy.generate();
if (!memberRepository.existsByProfileNickname(nickname)) {
return nickname;
}
}
}

private OauthInfo extractOauthInfo(OidcUser oidcUser) {
return OauthInfo.createOauthInfo(
oidcUser.getName(), oidcUser.getIssuer().toString(), oidcUser.getEmail());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.depromeet.domain.auth.application;

import com.depromeet.domain.auth.domain.OauthProvider;
import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import com.depromeet.infra.config.oidc.OidcProperties;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class IdTokenVerifier {

private final OidcProperties oidcProperties;
private final Map<OauthProvider, JwtDecoder> decoders =
Map.of(
OauthProvider.KAKAO, buildDecoder(OauthProvider.KAKAO.getJwkSetUrl()),
OauthProvider.APPLE, buildDecoder(OauthProvider.APPLE.getJwkSetUrl()));

private JwtDecoder buildDecoder(String jwkUrl) {
return NimbusJwtDecoder.withJwkSetUri(jwkUrl).build();
}

public OidcUser getOidcUser(String idToken, OauthProvider provider) {
Jwt jwt = getJwt(idToken, provider);
OidcIdToken oidcIdToken = getOidcIdToken(jwt);

validateIssuer(oidcIdToken, provider.getIssuer());
validateAudience(oidcIdToken, oidcProperties.getAudiences(provider));
validateNonce(oidcIdToken, provider);

return new DefaultOidcUser(null, oidcIdToken);
}

private Jwt getJwt(String idToken, OauthProvider provider) {
return decoders.get(provider).decode(idToken);
}

private void validateAudience(OidcIdToken oidcIdToken, List<String> targetAudiences) {
String idTokenAudience = oidcIdToken.getAudience().get(0);

if (idTokenAudience == null || !targetAudiences.contains(idTokenAudience)) {
throw new CustomException(ErrorCode.ID_TOKEN_VERIFICATION_FAILED);
}
}

private void validateIssuer(OidcIdToken oidcIdToken, String targetIssuer) {
String idTokenIssuer = oidcIdToken.getIssuer().toString();

if (idTokenIssuer == null || !idTokenIssuer.equals(targetIssuer)) {
throw new CustomException(ErrorCode.ID_TOKEN_VERIFICATION_FAILED);
}
}

private OidcIdToken getOidcIdToken(Jwt jwt) {
return new OidcIdToken(
jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
}

private void validateNonce(OidcIdToken idToken, OauthProvider provider) {
// TODO: 랜덤 nonce 사용하도록 개선
String idTokenNonce = idToken.getNonce();
String targetNonce = oidcProperties.nonce();

// 카카오, 애플 앱 토큰의 경우 라이브러리 문제로 nonce 검증 생략
if (isKakaoAppToken(idToken, provider) || isAppleAppToken(idToken, provider)) {
return;
}

if (idTokenNonce == null || !idTokenNonce.equals(targetNonce)) {
throw new CustomException(ErrorCode.ID_TOKEN_VERIFICATION_FAILED);
}
}

private boolean isKakaoAppToken(OidcIdToken idToken, OauthProvider provider) {
return provider == OauthProvider.KAKAO
&& idToken.getAudience().contains(oidcProperties.getKakaoAppAudience());
}

private boolean isAppleAppToken(OidcIdToken idToken, OauthProvider provider) {
return provider == OauthProvider.APPLE
&& idToken.getAudience().contains(oidcProperties.getAppleAppAudience());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.depromeet.domain.auth.application.nickname;

import com.depromeet.global.common.constants.NicknameGenerationConstants;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.stereotype.Component;

@Component
public class AnimalNicknameGenerator implements NicknameGenerationStrategy {

@Override
public String generate() {
int animalIndex =
ThreadLocalRandom.current()
.nextInt(NicknameGenerationConstants.ANIMAL_NAMES.length);
int prefixIndex =
ThreadLocalRandom.current()
.nextInt(NicknameGenerationConstants.PREFIX_NAMES.length);

String animalName = NicknameGenerationConstants.ANIMAL_NAMES[animalIndex];
String prefix = NicknameGenerationConstants.PREFIX_NAMES[prefixIndex];

return prefix + animalName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.depromeet.domain.auth.application.nickname;

public interface NicknameGenerationStrategy {
String generate();
}
27 changes: 27 additions & 0 deletions src/main/java/com/depromeet/domain/auth/domain/OauthProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.depromeet.domain.auth.domain;

import static com.depromeet.global.common.constants.SecurityConstants.*;

import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import java.util.Arrays;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum OauthProvider {
KAKAO(KAKAO_JWK_SET_URL, KAKAO_ISSUER),
APPLE(APPLE_JWK_SET_URL, APPLE_ISSUER),
;

private final String jwkSetUrl;
private final String issuer;

public static OauthProvider of(String issuer) {
return Arrays.stream(values())
.filter(oauthProvider -> oauthProvider.issuer.equals(issuer))
.findFirst()
.orElseThrow(() -> new CustomException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.depromeet.domain.auth.dto.request;

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

public record IdTokenRequest(
@NotNull(message = "Id Token은 비워둘 수 없습니다.") @Schema(description = "Id Token")
String idToken) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.depromeet.domain.auth.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record SocialLoginResponse(
@Schema(description = "엑세스 토큰", defaultValue = "accessToken") String accessToken,
@Schema(description = "리프레시 토큰", defaultValue = "refreshToken") String refreshToken,
@Schema(description = "게스트 여부", defaultValue = "true") boolean isGuest) {

public static SocialLoginResponse from(TokenPairResponse tokenPairResponse, boolean isGuest) {
return new SocialLoginResponse(
tokenPairResponse.accessToken(), tokenPairResponse.refreshToken(), isGuest);
}
}
Loading

0 comments on commit fb4afe1

Please sign in to comment.