diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f52b052 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/main/resources/HACKER-CONFIG"] + path = src/main/resources/HACKER-CONFIG + url = https://github.com/zaranaramorimori/HACKER-CONFIG.git diff --git a/build.gradle b/build.gradle index 6a12f40..dafcb64 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,6 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' @@ -31,7 +30,11 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/com/teamzzong/hacker/application/AuthService.java b/src/main/java/com/teamzzong/hacker/application/AuthService.java new file mode 100644 index 0000000..a327790 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/application/AuthService.java @@ -0,0 +1,68 @@ +package com.teamzzong.hacker.application; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.teamzzong.hacker.application.oauth.OauthClients; +import com.teamzzong.hacker.domain.AuthTokenPayload; +import com.teamzzong.hacker.domain.AuthTokenType; +import com.teamzzong.hacker.domain.AuthTokens; +import com.teamzzong.hacker.domain.Member; +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.LoginResponse; +import com.teamzzong.hacker.dto.SignUpRequest; +import com.teamzzong.hacker.dto.SignUpResponse; +import com.teamzzong.hacker.dto.UserInfo; +import com.teamzzong.hacker.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class AuthService { + + private final OauthClients oauthClients; + private final MemberRepository memberRepository; + private final AuthTokenProvider tokenProvider; + + public LoginResponse login(SocialType socialType, String code) { + UserInfo userInfo = oauthClients.requestUserInfo(socialType, code); + Optional optionalMember = memberRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); + if (optionalMember.isEmpty()) { + return LoginResponse.signUp(userInfo); + } + Member member = optionalMember.get(); + return LoginResponse.login(createAuthToken(member)); + } + + private AuthTokens createAuthToken(Member member) { + AuthTokenPayload payload = new AuthTokenPayload(member.getId()); + String accessToken = tokenProvider.generate(AuthTokenType.ACCESS, payload); + String refreshToken = tokenProvider.generate(AuthTokenType.REFRESH, payload); + return new AuthTokens(accessToken, refreshToken); + } + + public SignUpResponse signUp(SignUpRequest request) { + validateExistMember(request.socialType(), request.socialId()); + validateNickname(request.nickname()); + Member member = memberRepository.save(new Member(request.socialType(), request.socialId(), request.nickname())); + return SignUpResponse.from(createAuthToken(member)); + } + + private void validateExistMember(SocialType socialType, String socialId) { + memberRepository.findBySocialTypeAndSocialId(socialType, socialId) + .ifPresent(member -> { + throw new IllegalArgumentException("이미 존재하는 회원입니다."); + }); + } + + private void validateNickname(String nickname) { + memberRepository.findByNickname(nickname) + .ifPresent(member -> { + throw new IllegalArgumentException("이미 존재하는 닉네임입니다."); + }); + } +} diff --git a/src/main/java/com/teamzzong/hacker/application/AuthTokenProvider.java b/src/main/java/com/teamzzong/hacker/application/AuthTokenProvider.java new file mode 100644 index 0000000..8204923 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/application/AuthTokenProvider.java @@ -0,0 +1,11 @@ +package com.teamzzong.hacker.application; + +import com.teamzzong.hacker.domain.AuthTokenPayload; +import com.teamzzong.hacker.domain.AuthTokenType; + +public interface AuthTokenProvider { + + String generate(AuthTokenType type, AuthTokenPayload payload); + + AuthTokenPayload extract(AuthTokenType type, String token); +} diff --git a/src/main/java/com/teamzzong/hacker/application/oauth/OauthClient.java b/src/main/java/com/teamzzong/hacker/application/oauth/OauthClient.java new file mode 100644 index 0000000..8e6d442 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/application/oauth/OauthClient.java @@ -0,0 +1,13 @@ +package com.teamzzong.hacker.application.oauth; + +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.UserInfo; + +public interface OauthClient { + + SocialType socialType(); + + String requestAccessToken(String code); + + UserInfo requestUserInfo(String accessToken); +} diff --git a/src/main/java/com/teamzzong/hacker/application/oauth/OauthClients.java b/src/main/java/com/teamzzong/hacker/application/oauth/OauthClients.java new file mode 100644 index 0000000..ee02503 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/application/oauth/OauthClients.java @@ -0,0 +1,28 @@ +package com.teamzzong.hacker.application.oauth; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.UserInfo; + +public class OauthClients { + + private final Map clients; + + public OauthClients(Set clients) { + this.clients = clients.stream() + .collect(Collectors.toMap(OauthClient::socialType, oauthClient -> oauthClient)); + } + + public UserInfo requestUserInfo(SocialType socialType, String code) { + OauthClient oauthClient = clients.get(socialType); + if (oauthClient == null) { + throw new IllegalArgumentException("올바르지 않은 SocialType 입니다."); + } + String accessToken = oauthClient.requestAccessToken(code); + return oauthClient.requestUserInfo(accessToken); + } + +} diff --git a/src/main/java/com/teamzzong/hacker/config/AuthConfig.java b/src/main/java/com/teamzzong/hacker/config/AuthConfig.java new file mode 100644 index 0000000..8c2c4f2 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/config/AuthConfig.java @@ -0,0 +1,23 @@ +package com.teamzzong.hacker.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.teamzzong.hacker.application.AuthTokenProvider; +import com.teamzzong.hacker.infrastructure.jwt.JwtAuthTokenProvider; + +import lombok.AllArgsConstructor; + +@Configuration +@EnableConfigurationProperties(JwtAuthProperty.class) +@AllArgsConstructor +public class AuthConfig { + + private final JwtAuthProperty property; + + @Bean + public AuthTokenProvider authTokenProvider() { + return JwtAuthTokenProvider.from(property); + } +} diff --git a/src/main/java/com/teamzzong/hacker/config/GithubOauthProperty.java b/src/main/java/com/teamzzong/hacker/config/GithubOauthProperty.java new file mode 100644 index 0000000..9460682 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/config/GithubOauthProperty.java @@ -0,0 +1,14 @@ +package com.teamzzong.hacker.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth2.github") +public record GithubOauthProperty( + String clientId, + String clientSecret, + String redirectUri, + String tokenUri, + String userInfoUri +) { + +} diff --git a/src/main/java/com/teamzzong/hacker/config/JwtAuthProperty.java b/src/main/java/com/teamzzong/hacker/config/JwtAuthProperty.java new file mode 100644 index 0000000..9dbd301 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/config/JwtAuthProperty.java @@ -0,0 +1,19 @@ +package com.teamzzong.hacker.config; + +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import com.teamzzong.hacker.domain.AuthTokenType; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@ConfigurationProperties(prefix = "auth") +@AllArgsConstructor +@Getter +public class JwtAuthProperty { + + private final Map expirationMinute; + private final Map secretKey; +} diff --git a/src/main/java/com/teamzzong/hacker/config/LoginConfig.java b/src/main/java/com/teamzzong/hacker/config/LoginConfig.java new file mode 100644 index 0000000..e42be30 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/config/LoginConfig.java @@ -0,0 +1,24 @@ +package com.teamzzong.hacker.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.teamzzong.hacker.presentation.LoginMemberResolver; + +@Configuration +public class LoginConfig implements WebMvcConfigurer { + + private final LoginMemberResolver loginMemberResolver; + + public LoginConfig(LoginMemberResolver loginMemberResolver) { + this.loginMemberResolver = loginMemberResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberResolver); + } +} diff --git a/src/main/java/com/teamzzong/hacker/config/OauthConfig.java b/src/main/java/com/teamzzong/hacker/config/OauthConfig.java new file mode 100644 index 0000000..d7cedbd --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/config/OauthConfig.java @@ -0,0 +1,20 @@ +package com.teamzzong.hacker.config; + +import java.util.Set; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.teamzzong.hacker.application.oauth.OauthClient; +import com.teamzzong.hacker.application.oauth.OauthClients; + +@Configuration +@EnableConfigurationProperties({GithubOauthProperty.class}) +public class OauthConfig { + + @Bean + public OauthClients oAuth2Clients(Set clients) { + return new OauthClients(clients); + } +} diff --git a/src/main/java/com/teamzzong/hacker/domain/AuthTokenPayload.java b/src/main/java/com/teamzzong/hacker/domain/AuthTokenPayload.java new file mode 100644 index 0000000..c64a878 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/domain/AuthTokenPayload.java @@ -0,0 +1,6 @@ +package com.teamzzong.hacker.domain; + +public record AuthTokenPayload( + Long memberId +) { +} diff --git a/src/main/java/com/teamzzong/hacker/domain/AuthTokenType.java b/src/main/java/com/teamzzong/hacker/domain/AuthTokenType.java new file mode 100644 index 0000000..5be1323 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/domain/AuthTokenType.java @@ -0,0 +1,7 @@ +package com.teamzzong.hacker.domain; + +public enum AuthTokenType { + ACCESS, + REFRESH, + ; +} diff --git a/src/main/java/com/teamzzong/hacker/domain/AuthTokens.java b/src/main/java/com/teamzzong/hacker/domain/AuthTokens.java new file mode 100644 index 0000000..99baa4b --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/domain/AuthTokens.java @@ -0,0 +1,12 @@ +package com.teamzzong.hacker.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class AuthTokens { + + private final String accessToken; + private final String refreshToken; +} diff --git a/src/main/java/com/teamzzong/hacker/domain/Member.java b/src/main/java/com/teamzzong/hacker/domain/Member.java index a04ca5f..6ac9702 100644 --- a/src/main/java/com/teamzzong/hacker/domain/Member.java +++ b/src/main/java/com/teamzzong/hacker/domain/Member.java @@ -1,14 +1,34 @@ package com.teamzzong.hacker.domain; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @Enumerated(value = EnumType.STRING) + private SocialType socialType; + + private String socialId; + + private String nickname; + + public Member(SocialType socialType, String socialId, String nickname) { + this(null, socialType, socialId, nickname); + } } diff --git a/src/main/java/com/teamzzong/hacker/domain/SocialType.java b/src/main/java/com/teamzzong/hacker/domain/SocialType.java new file mode 100644 index 0000000..c0b69fc --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/domain/SocialType.java @@ -0,0 +1,6 @@ +package com.teamzzong.hacker.domain; + +public enum SocialType { + GITHUB, + ; +} diff --git a/src/main/java/com/teamzzong/hacker/dto/LoginMember.java b/src/main/java/com/teamzzong/hacker/dto/LoginMember.java new file mode 100644 index 0000000..432112b --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/LoginMember.java @@ -0,0 +1,6 @@ +package com.teamzzong.hacker.dto; + +public record LoginMember( + Long memberId +) { +} diff --git a/src/main/java/com/teamzzong/hacker/dto/LoginResponse.java b/src/main/java/com/teamzzong/hacker/dto/LoginResponse.java new file mode 100644 index 0000000..9d66cc1 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/LoginResponse.java @@ -0,0 +1,26 @@ +package com.teamzzong.hacker.dto; + +import com.teamzzong.hacker.domain.AuthTokens; + +public record LoginResponse( + Boolean isNew, + LoginTokenResponse tokens, + LoginUserInfoResponse userInfo +) { + + public static LoginResponse login(AuthTokens authTokens) { + return new LoginResponse( + false, + new LoginTokenResponse(authTokens.getAccessToken(), authTokens.getRefreshToken()), + null + ); + } + + public static LoginResponse signUp(UserInfo userInfo) { + return new LoginResponse( + true, + null, + LoginUserInfoResponse.from(userInfo) + ); + } +} diff --git a/src/main/java/com/teamzzong/hacker/dto/LoginTokenResponse.java b/src/main/java/com/teamzzong/hacker/dto/LoginTokenResponse.java new file mode 100644 index 0000000..c07a0f5 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/LoginTokenResponse.java @@ -0,0 +1,7 @@ +package com.teamzzong.hacker.dto; + +public record LoginTokenResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/teamzzong/hacker/dto/LoginUserInfoResponse.java b/src/main/java/com/teamzzong/hacker/dto/LoginUserInfoResponse.java new file mode 100644 index 0000000..fda845a --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/LoginUserInfoResponse.java @@ -0,0 +1,20 @@ +package com.teamzzong.hacker.dto; + +import com.teamzzong.hacker.domain.SocialType; + +public record LoginUserInfoResponse( + SocialType socialType, + String socialId, + String username, + String profileImage +) { + + public static LoginUserInfoResponse from(UserInfo userInfo) { + return new LoginUserInfoResponse( + userInfo.socialType(), + userInfo.socialId(), + userInfo.nickname(), + userInfo.profileImage() + ); + } +} diff --git a/src/main/java/com/teamzzong/hacker/dto/SignUpRequest.java b/src/main/java/com/teamzzong/hacker/dto/SignUpRequest.java new file mode 100644 index 0000000..b1de6ce --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/SignUpRequest.java @@ -0,0 +1,11 @@ +package com.teamzzong.hacker.dto; + +import com.teamzzong.hacker.domain.SocialType; + +public record SignUpRequest( + SocialType socialType, + String socialId, + String username, + String nickname +) { +} diff --git a/src/main/java/com/teamzzong/hacker/dto/SignUpResponse.java b/src/main/java/com/teamzzong/hacker/dto/SignUpResponse.java new file mode 100644 index 0000000..a53dfd3 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/SignUpResponse.java @@ -0,0 +1,16 @@ +package com.teamzzong.hacker.dto; + +import com.teamzzong.hacker.domain.AuthTokens; + +public record SignUpResponse( + String accessToken, + String refreshToken +) { + + public static SignUpResponse from(AuthTokens authTokens) { + return new SignUpResponse( + authTokens.getAccessToken(), + authTokens.getRefreshToken() + ); + } +} diff --git a/src/main/java/com/teamzzong/hacker/dto/UserInfo.java b/src/main/java/com/teamzzong/hacker/dto/UserInfo.java new file mode 100644 index 0000000..87b0808 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/UserInfo.java @@ -0,0 +1,12 @@ +package com.teamzzong.hacker.dto; + +import com.teamzzong.hacker.domain.SocialType; + +public record UserInfo( + SocialType socialType, + String socialId, + String nickname, + String profileImage +) { + +} diff --git a/src/main/java/com/teamzzong/hacker/dto/oauth/GithubTokenResponse.java b/src/main/java/com/teamzzong/hacker/dto/oauth/GithubTokenResponse.java new file mode 100644 index 0000000..693b647 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/oauth/GithubTokenResponse.java @@ -0,0 +1,8 @@ +package com.teamzzong.hacker.dto.oauth; + +public record GithubTokenResponse( + String accessToken, + String scope, + String tokenType +) { +} diff --git a/src/main/java/com/teamzzong/hacker/dto/oauth/GithubUserInfoResponse.java b/src/main/java/com/teamzzong/hacker/dto/oauth/GithubUserInfoResponse.java new file mode 100644 index 0000000..4b359ce --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/dto/oauth/GithubUserInfoResponse.java @@ -0,0 +1,20 @@ +package com.teamzzong.hacker.dto.oauth; + +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.UserInfo; + +public record GithubUserInfoResponse( + String id, + String login, + String avatarUrl +) { + + public UserInfo toUserInfo() { + return new UserInfo( + SocialType.GITHUB, + id, + login, + avatarUrl + ); + } +} diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/MemberRepository.java b/src/main/java/com/teamzzong/hacker/infrastructure/MemberRepository.java index d12e6e1..de0819d 100644 --- a/src/main/java/com/teamzzong/hacker/infrastructure/MemberRepository.java +++ b/src/main/java/com/teamzzong/hacker/infrastructure/MemberRepository.java @@ -1,8 +1,15 @@ package com.teamzzong.hacker.infrastructure; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.teamzzong.hacker.domain.Member; +import com.teamzzong.hacker.domain.SocialType; public interface MemberRepository extends JpaRepository { + + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); + + Optional findByNickname(String nickname); } diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/jwt/JwtAuthTokenProvider.java b/src/main/java/com/teamzzong/hacker/infrastructure/jwt/JwtAuthTokenProvider.java new file mode 100644 index 0000000..4918629 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/infrastructure/jwt/JwtAuthTokenProvider.java @@ -0,0 +1,89 @@ +package com.teamzzong.hacker.infrastructure.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.crypto.SecretKey; + +import com.teamzzong.hacker.application.AuthTokenProvider; +import com.teamzzong.hacker.config.JwtAuthProperty; +import com.teamzzong.hacker.domain.AuthTokenPayload; +import com.teamzzong.hacker.domain.AuthTokenType; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +public class JwtAuthTokenProvider implements AuthTokenProvider { + + private static final int SECOND_FACTOR = 60; + private static final int MILLISECOND_FACTOR = 1000; + private static final String MEMBER_ID_KEY = "id"; + + private final Map secretKeys; + private final Map expirationMinutes; + + private JwtAuthTokenProvider(Map secretKeys, + Map expirationMinutes) { + this.secretKeys = secretKeys; + this.expirationMinutes = expirationMinutes; + } + + public static JwtAuthTokenProvider from(JwtAuthProperty property) { + Map secretKeys = property.getSecretKey() + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> Keys.hmacShaKeyFor(entry.getValue().getBytes(StandardCharsets.UTF_8)) + )); + return new JwtAuthTokenProvider(secretKeys, property.getExpirationMinute()); + } + + @Override + public String generate(AuthTokenType type, AuthTokenPayload payload) { + Integer expMinutes = expirationMinutes.get(type); + SecretKey secretKey = secretKeys.get(type); + if (expMinutes == null || secretKey == null) { + throw new IllegalArgumentException(); // TODO + } + Date now = new Date(); + Date expiration = new Date(now.getTime() + expMinutes * SECOND_FACTOR * MILLISECOND_FACTOR); + return Jwts.builder() + .claim(MEMBER_ID_KEY, payload.memberId()) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + @Override + public AuthTokenPayload extract(AuthTokenType type, String token) { + SecretKey secretKey = secretKeys.get(type); + if (secretKey == null) { + throw new IllegalArgumentException(); // TODO + } + Claims claims = getClaims(secretKey, token); + Long memberId = claims.get(MEMBER_ID_KEY, Long.class); + return new AuthTokenPayload(memberId); + } + + private Claims getClaims(SecretKey secretKey, String code) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(code) + .getBody(); + } catch (ExpiredJwtException e) { + throw new IllegalArgumentException("토큰 만료"); // TODO + } catch (JwtException | IllegalArgumentException e) { + throw new IllegalArgumentException("토큰 유효 X"); // TODO + } + } +} diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubExceptionHandler.java b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubExceptionHandler.java new file mode 100644 index 0000000..4bf466e --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubExceptionHandler.java @@ -0,0 +1,32 @@ +package com.teamzzong.hacker.infrastructure.oauth; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class GithubExceptionHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.is4xxClientError()) { + log4xxError(response); + // throw new IllegalArgumentException(); // TODO + } + if (statusCode.is5xxServerError()) { + throw new IllegalArgumentException("[Github] Internal Server Error"); // TODO + } + } + + private void log4xxError(ClientHttpResponse response) throws IOException { + log.warn("[{}] {}", + response.getStatusText(), + new String(getResponseBody(response), StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubOauthClient.java b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubOauthClient.java new file mode 100644 index 0000000..1c68723 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubOauthClient.java @@ -0,0 +1,38 @@ +package com.teamzzong.hacker.infrastructure.oauth; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Component; + +import com.teamzzong.hacker.application.oauth.OauthClient; +import com.teamzzong.hacker.config.GithubOauthProperty; +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.UserInfo; + +@Component +public class GithubOauthClient implements OauthClient { + + private final GithubOauthProperty property; + private final GithubTokenClient tokenClient; + private final GithubUserInfoClient userInfoClient; + + public GithubOauthClient(GithubOauthProperty property, RestTemplateBuilder restTemplateBuilder) { + this.property = property; + this.tokenClient = new GithubTokenClient(property, restTemplateBuilder); + this.userInfoClient = new GithubUserInfoClient(property, restTemplateBuilder); + } + + @Override + public SocialType socialType() { + return SocialType.GITHUB; + } + + @Override + public String requestAccessToken(String code) { + return tokenClient.request(code); + } + + @Override + public UserInfo requestUserInfo(String accessToken) { + return userInfoClient.request(accessToken); + } +} diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubTokenClient.java b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubTokenClient.java new file mode 100644 index 0000000..0d26a5d --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubTokenClient.java @@ -0,0 +1,54 @@ +package com.teamzzong.hacker.infrastructure.oauth; + +import java.util.List; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import com.teamzzong.hacker.config.GithubOauthProperty; +import com.teamzzong.hacker.dto.oauth.GithubTokenResponse; + +public class GithubTokenClient { + + private final GithubOauthProperty property; + private final RestTemplate restTemplate; + + public GithubTokenClient(GithubOauthProperty property, RestTemplateBuilder restTemplateBuilder) { + this.property = property; + this.restTemplate = restTemplateBuilder + .errorHandler(new GithubExceptionHandler()) + .build(); + } + + public String request(String code) { + String uri = getRequestUri(code); + HttpHeaders header = getRequestHeader(); + GithubTokenResponse response = restTemplate + .postForEntity( + uri, + new HttpEntity<>(header), + GithubTokenResponse.class + ) + .getBody(); + + return response.accessToken(); + } + + private String getRequestUri(String code) { + return UriComponentsBuilder.fromUriString(property.tokenUri()) + .queryParam("client_id", property.clientId()) + .queryParam("client_secret", property.clientSecret()) + .queryParam("code", code) + .toUriString(); + } + + private HttpHeaders getRequestHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + return headers; + } +} diff --git a/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubUserInfoClient.java b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubUserInfoClient.java new file mode 100644 index 0000000..46cfadc --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/infrastructure/oauth/GithubUserInfoClient.java @@ -0,0 +1,49 @@ +package com.teamzzong.hacker.infrastructure.oauth; + +import java.util.List; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +import com.teamzzong.hacker.config.GithubOauthProperty; +import com.teamzzong.hacker.dto.UserInfo; +import com.teamzzong.hacker.dto.oauth.GithubUserInfoResponse; + +public class GithubUserInfoClient { + + private final GithubOauthProperty property; + private final RestTemplate restTemplate; + + public GithubUserInfoClient(GithubOauthProperty property, RestTemplateBuilder restTemplateBuilder) { + this.property = property; + this.restTemplate = restTemplateBuilder + .errorHandler(new GithubExceptionHandler()) + .build(); + } + + public UserInfo request(String accessToken) { + HttpHeaders headers = getHttpHeaders(accessToken); + + GithubUserInfoResponse response = restTemplate + .exchange( + property.userInfoUri(), + HttpMethod.GET, + new HttpEntity<>(headers), + GithubUserInfoResponse.class + ) + .getBody(); + + return response.toUserInfo(); + } + + private HttpHeaders getHttpHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + return headers; + } +} diff --git a/src/main/java/com/teamzzong/hacker/presentation/AuthController.java b/src/main/java/com/teamzzong/hacker/presentation/AuthController.java new file mode 100644 index 0000000..0b51288 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/presentation/AuthController.java @@ -0,0 +1,27 @@ +package com.teamzzong.hacker.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.teamzzong.hacker.application.AuthService; +import com.teamzzong.hacker.domain.SocialType; +import com.teamzzong.hacker.dto.LoginResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login/{socialType}/{code}") + public ResponseEntity login(@PathVariable SocialType socialType, @PathVariable String code) { + LoginResponse login = authService.login(socialType, code); + return ResponseEntity.ok(login); + } +} diff --git a/src/main/java/com/teamzzong/hacker/presentation/Login.java b/src/main/java/com/teamzzong/hacker/presentation/Login.java new file mode 100644 index 0000000..96ee334 --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/presentation/Login.java @@ -0,0 +1,11 @@ +package com.teamzzong.hacker.presentation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Login { +} diff --git a/src/main/java/com/teamzzong/hacker/presentation/LoginMemberResolver.java b/src/main/java/com/teamzzong/hacker/presentation/LoginMemberResolver.java new file mode 100644 index 0000000..426064c --- /dev/null +++ b/src/main/java/com/teamzzong/hacker/presentation/LoginMemberResolver.java @@ -0,0 +1,53 @@ +package com.teamzzong.hacker.presentation; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.teamzzong.hacker.application.AuthTokenProvider; +import com.teamzzong.hacker.domain.AuthTokenPayload; +import com.teamzzong.hacker.domain.AuthTokenType; +import com.teamzzong.hacker.dto.LoginMember; + +import lombok.AllArgsConstructor; + +@Component +@AllArgsConstructor +public class LoginMemberResolver implements HandlerMethodArgumentResolver { + + private static final String BEARER_TOKEN_PREFIX = "Bearer "; + + private final AuthTokenProvider authTokenProvider; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class) && parameter.hasParameterAnnotation(Login.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String header = webRequest.getHeader(HttpHeaders.AUTHORIZATION); + String token = extractToken(header); + AuthTokenPayload payload = authTokenProvider.extract(AuthTokenType.ACCESS, token); + return new LoginMember(payload.memberId()); + } + + private String extractToken(String header) { + validateHeader(header); + return header.substring(BEARER_TOKEN_PREFIX.length()).trim(); + } + + private void validateHeader(String header) { + if (header == null) { + throw new IllegalArgumentException("토큰이 없습니다."); // TODO + } + if (!header.toLowerCase().startsWith(BEARER_TOKEN_PREFIX.toLowerCase())) { + throw new IllegalArgumentException("Bearer Type의 토큰이 아닙니다."); // TODO + } + } +} diff --git a/src/main/resources/HACKER-CONFIG b/src/main/resources/HACKER-CONFIG new file mode 160000 index 0000000..0389d3b --- /dev/null +++ b/src/main/resources/HACKER-CONFIG @@ -0,0 +1 @@ +Subproject commit 0389d3b86eca6627d182ce38039a0230c6af1c6d diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..0bec47c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,4 @@ +spring: + config: + import: + - classpath:/HACKER-CONFIG/application.yml