feat: 소셜로그인 및 유저 등급

This commit is contained in:
2026-02-26 23:21:18 +09:00
parent 8b2c747466
commit 99dc595044
26 changed files with 457 additions and 35 deletions

View File

@@ -57,7 +57,7 @@ public class TokenProvider {
/** 서명/만료/issuer 검증 */
public boolean validate(String token) {
try {
parser.parseClaimsJws(stripBearer(token));
parser.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
@@ -94,7 +94,7 @@ public class TokenProvider {
/** Claims 추출(검증 포함) */
public Claims getClaims(String token) {
return parser.parseClaimsJws(stripBearer(token)).getBody();
return parser.parseClaimsJws(token).getBody();
}
public String getSubject(String token) {
@@ -108,9 +108,4 @@ public class TokenProvider {
public List<String> getRoles(String token) {
return getClaims(token).get("roles", List.class);
}
private String stripBearer(String token) {
if (token == null || !token.startsWith("Bearer ")) return null;
return token.substring(7);
}
}

View File

@@ -14,6 +14,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@RequiredArgsConstructor
@@ -34,7 +39,8 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(allAuthorizedUrls).permitAll()
@@ -58,6 +64,21 @@ public class SecurityConfig {
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();

View File

@@ -2,15 +2,18 @@ package com.boilerplate.core.auth.api.controller;
import com.boilerplate.core.auth.api.dto.req.LoginRequest;
import com.boilerplate.core.auth.api.dto.req.SignUpRequest;
import com.boilerplate.core.auth.api.dto.req.SocialLoginRequest;
import com.boilerplate.core.auth.api.dto.res.LoginResponse;
import com.boilerplate.core.auth.api.dto.res.SignUpResponse;
import com.boilerplate.core.auth.application.LoginUseCase;
import com.boilerplate.core.auth.application.RegisterUseCase;
import com.boilerplate.core.auth.application.SocialLoginUseCase;
import com.boilerplate.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.boilerplate.core.auth.application.dto.result.LoginResult;
import com.boilerplate.core.auth.application.dto.result.RegisterUseCaseResult;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,13 +26,16 @@ public class AuthController {
private final RegisterUseCase registerUseCase;
private final LoginUseCase loginUseCase;
private final SocialLoginUseCase socialLoginUseCase;
@PostMapping("/register")
public ResponseEntity<SignUpResponse> register(
@RequestBody SignUpRequest request
) {
RegisterUseCaseResult result = registerUseCase.execute(
RegisterUseCaseCommand.builder().loginId(request.getLoginId()).password(request.getPassword()).build());
RegisterUseCaseResult result = registerUseCase.execute(RegisterUseCaseCommand.builder()
.email(request.getEmail())
.password(request.getPassword())
.build());
SignUpResponse response = SignUpResponse.builder()
.accessToken(result.getAccessToken())
@@ -43,7 +49,7 @@ public class AuthController {
public ResponseEntity<LoginResponse> login(
@RequestBody LoginRequest request
) {
LoginResult result = loginUseCase.execute(request.getLoginId(), request.getPassword());
LoginResult result = loginUseCase.execute(request.getEmail(), request.getPassword());
LoginResponse response = LoginResponse.builder()
.accessToken(result.getAccessToken())
.refreshToken(result.getRefreshToken())
@@ -51,4 +57,15 @@ public class AuthController {
return ResponseEntity.ok(response);
}
@PostMapping("/social")
public ResponseEntity<LoginResponse> socialLogin(@RequestBody SocialLoginRequest request) {
LoginResult result = socialLoginUseCase.execute(request.getProvider(), request.getToken());
LoginResponse response = LoginResponse.builder()
.accessToken(result.getAccessToken())
.refreshToken(result.getRefreshToken())
.build();
return ResponseEntity.ok(response);
}
}

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
@Setter
public class LoginRequest {
private String loginId;
private String email;
private String password;
}

View File

@@ -11,6 +11,6 @@ import lombok.NoArgsConstructor;
@Getter
public class SignUpRequest {
private String loginId;
private String email;
private String password;
}

View File

@@ -0,0 +1,16 @@
package com.boilerplate.core.auth.api.dto.req;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class SocialLoginRequest {
private SocialProvider provider;
private String token;
}

View File

@@ -0,0 +1,16 @@
package com.boilerplate.core.auth.api.dto.req;
import com.fasterxml.jackson.annotation.JsonCreator;
import java.util.stream.Stream;
public enum SocialProvider {
LOCAL, GOOGLE, FACEBOOK;
@JsonCreator
public static SocialProvider from(String value) {
return Stream.of(SocialProvider.values())
.filter(p -> p.name().equalsIgnoreCase(value))
.findFirst()
.orElse(null);
}
}

View File

@@ -15,8 +15,8 @@ public class LoginUseCase {
private final AccountService accountService;
private final TokenProvider tokenProvider;
public LoginResult execute(String loginId, String password) {
long accountId = accountService.login(loginId, password);
public LoginResult execute(String email, String password) {
long accountId = accountService.login(email, password);
String accessToken = tokenProvider.createUserAccessToken(accountId);
String refreshToken = tokenProvider.createUserRefreshToken(accountId);

View File

@@ -4,7 +4,7 @@ import com.boilerplate.common.TokenProvider;
import com.boilerplate.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.boilerplate.core.auth.application.dto.result.RegisterUseCaseResult;
import com.boilerplate.core.auth.domain.service.AccountService;
import com.boilerplate.core.exception.auth.ExistLoginIdException;
import com.boilerplate.core.exception.auth.ExistEmailException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -18,15 +18,15 @@ public final class RegisterUseCase {
private final TokenProvider tokenProvider;
public RegisterUseCaseResult execute(RegisterUseCaseCommand command) {
String loginId = command.getLoginId();
String email = command.getEmail();
boolean isExistLoginId = accountService.isExistLoginId(loginId);
boolean isExistEmail = accountService.isExistEmail(email);
if (isExistLoginId) {
throw new ExistLoginIdException(loginId);
if (isExistEmail) {
throw new ExistEmailException(email);
}
long accountId = accountService.register(loginId, command.getPassword());
long accountId = accountService.register(email, command.getPassword());
String accessToken = tokenProvider.createUserAccessToken(accountId);
String refreshToken = tokenProvider.createUserRefreshToken(accountId);

View File

@@ -0,0 +1,42 @@
package com.boilerplate.core.auth.application;
import com.boilerplate.common.TokenProvider;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import com.boilerplate.core.auth.application.dto.result.LoginResult;
import com.boilerplate.core.auth.domain.SocialProviderClient;
import com.boilerplate.core.auth.domain.SocialUserInfo;
import com.boilerplate.core.auth.domain.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Component
@RequiredArgsConstructor
public class SocialLoginUseCase {
private final List<SocialProviderClient> socialProviderClients;
private final AccountService accountService;
private final TokenProvider tokenProvider;
@Transactional
public LoginResult execute(SocialProvider provider, String token) {
SocialProviderClient client = socialProviderClients.stream()
.filter(c -> c.getProvider() == provider)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported provider: " + provider));
SocialUserInfo userInfo = client.getUserInfo(token);
long accountId = accountService.getOrCreateSocialAccount(userInfo);
String accessToken = tokenProvider.createUserAccessToken(accountId);
String refreshToken = tokenProvider.createUserRefreshToken(accountId);
return LoginResult.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}

View File

@@ -11,7 +11,7 @@ import lombok.NoArgsConstructor;
@Getter
public class RegisterUseCaseCommand {
private String loginId;
private String email;
private String password;
}

View File

@@ -36,6 +36,10 @@ public final class User implements UserDetails {
return accountId;
}
public Long getId() {
return accountId;
}
@Override
public String getUsername() {
return null;

View File

@@ -0,0 +1,8 @@
package com.boilerplate.core.auth.domain;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
public interface SocialProviderClient {
SocialUserInfo getUserInfo(String token);
SocialProvider getProvider();
}

View File

@@ -0,0 +1,14 @@
package com.boilerplate.core.auth.domain;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class SocialUserInfo {
private final String providerId;
private final String email;
private final String name;
private final SocialProvider provider;
}

View File

@@ -1,6 +1,9 @@
package com.boilerplate.core.auth.domain.entity;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@@ -24,10 +27,17 @@ public class Account {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String loginId;
private String email;
private String passwordHash;
private String name;
@Enumerated(EnumType.STRING)
@jakarta.persistence.Column(name = "auth_provider")
private SocialProvider provider;
@jakarta.persistence.Column(name = "oauth_id")
private String providerId;
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();

View File

@@ -0,0 +1,27 @@
package com.boilerplate.core.auth.domain.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@ToString
public class Grade {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
}

View File

@@ -0,0 +1,31 @@
package com.boilerplate.core.auth.domain.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@ToString
public class GradeHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long accountId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "grade_id")
private Grade grade;
private String status; // ACTIVE, EXPIRED, CANCELED
@Builder.Default
private LocalDateTime purchasedAt = LocalDateTime.now();
private LocalDateTime expiresAt;
}

View File

@@ -1,5 +1,6 @@
package com.boilerplate.core.auth.domain.repository;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import com.boilerplate.core.auth.domain.entity.Account;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -7,5 +8,6 @@ import org.springframework.stereotype.Repository;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByLoginId(String loginId);
Optional<Account> findByEmail(String email);
Optional<Account> findByProviderAndProviderId(SocialProvider provider, String providerId);
}

View File

@@ -0,0 +1,19 @@
package com.boilerplate.core.auth.domain.repository;
import com.boilerplate.core.auth.domain.entity.GradeHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface GradeHistoryRepository extends JpaRepository<GradeHistory, Long> {
@Query("SELECT gh FROM GradeHistory gh " +
"JOIN FETCH gh.grade " +
"WHERE gh.accountId = :accountId AND gh.status = 'ACTIVE' " +
"ORDER BY gh.purchasedAt DESC LIMIT 1")
Optional<GradeHistory> findCurrentActiveGrade(@Param("accountId") Long accountId);
}

View File

@@ -0,0 +1,11 @@
package com.boilerplate.core.auth.domain.repository;
import com.boilerplate.core.auth.domain.entity.Grade;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface GradeRepository extends JpaRepository<Grade, Long> {
Optional<Grade> findByName(String name);
}

View File

@@ -1,7 +1,13 @@
package com.boilerplate.core.auth.domain.service;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import com.boilerplate.core.auth.domain.SocialUserInfo;
import com.boilerplate.core.auth.domain.entity.Account;
import com.boilerplate.core.auth.domain.entity.Grade;
import com.boilerplate.core.auth.domain.entity.GradeHistory;
import com.boilerplate.core.auth.domain.repository.AccountRepository;
import com.boilerplate.core.auth.domain.repository.GradeHistoryRepository;
import com.boilerplate.core.auth.domain.repository.GradeRepository;
import com.boilerplate.core.exception.auth.InvalidCredentialsException;
import com.boilerplate.core.exception.common.NotExistEntityException;
import lombok.RequiredArgsConstructor;
@@ -16,37 +22,78 @@ import org.springframework.transaction.annotation.Transactional;
public class AccountService {
private final AccountRepository accountRepository;
private final GradeRepository gradeRepository;
private final GradeHistoryRepository gradeHistoryRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public long register(String loginId, String password) {
log.info("회원 등록: {}", loginId);
public long register(String email, String password) {
log.info("회원 등록: {}", email);
String hashedPassword = passwordEncoder.encode(password);
Grade normalGrade = gradeRepository.findByName("NORMAL")
.orElseThrow(() -> new NotExistEntityException(Grade.class.getName()));
Account account = Account.builder()
.loginId(loginId)
.email(email)
.name("임시 이름")
.passwordHash(hashedPassword)
.provider(SocialProvider.LOCAL)
.build();
log.info("account: {}", account);
accountRepository.save(account);
// 등급 이력 생성
GradeHistory gradeHistory = GradeHistory.builder()
.accountId(account.getId())
.grade(normalGrade)
.status("ACTIVE")
.build();
gradeHistoryRepository.save(gradeHistory);
return account.getId();
}
public boolean isExistLoginId(String loginId) {
log.info("로그인 아이디 존재 여부 조회");
@Transactional
public long getOrCreateSocialAccount(SocialUserInfo userInfo) {
return accountRepository.findByProviderAndProviderId(userInfo.getProvider(), userInfo.getProviderId())
.map(Account::getId)
.orElseGet(() -> {
Grade normalGrade = gradeRepository.findByName("NORMAL")
.orElseThrow(() -> new NotExistEntityException(Grade.class.getName()));
return accountRepository.findByLoginId(loginId).isPresent();
Account newAccount = Account.builder()
.email(userInfo.getEmail())
.name(userInfo.getName())
.provider(userInfo.getProvider())
.providerId(userInfo.getProviderId())
.build();
accountRepository.save(newAccount);
// 등급 이력 생성
GradeHistory gradeHistory = GradeHistory.builder()
.accountId(newAccount.getId())
.grade(normalGrade)
.status("ACTIVE")
.build();
gradeHistoryRepository.save(gradeHistory);
return newAccount.getId();
});
}
public long login(String loginId, String password) {
public boolean isExistEmail(String email) {
log.info("로그인 아이디 존재 여부 조회");
return accountRepository.findByEmail(email).isPresent();
}
public long login(String email, String password) {
log.info("로그인");
Account account = accountRepository.findByLoginId(loginId)
Account account = accountRepository.findByEmail(email)
.orElseThrow(() -> new NotExistEntityException(Account.class.getName()));
if (!passwordEncoder.matches(password, account.getPasswordHash())) {

View File

@@ -0,0 +1,70 @@
package com.boilerplate.core.auth.infra.social;
import com.boilerplate.core.auth.api.dto.req.SocialProvider;
import com.boilerplate.core.auth.domain.SocialProviderClient;
import com.boilerplate.core.auth.domain.SocialUserInfo;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
@Component
@Slf4j
@RequiredArgsConstructor
public class GoogleSocialProviderClient implements SocialProviderClient {
private static final String GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
@Override
public SocialUserInfo getUserInfo(String accessToken) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
try {
ResponseEntity<Map> response = restTemplate.exchange(
GOOGLE_USERINFO_URL,
HttpMethod.GET,
entity,
Map.class
);
Map<String, Object> body = response.getBody();
if (body == null) {
throw new RuntimeException("Failed to get response from Google");
}
String userId = (String) body.get("sub");
String email = (String) body.get("email");
String name = (String) body.get("name");
log.info("google user name: {}", userId);
log.info("email: {}", email);
log.info("name: {}", name);
return SocialUserInfo.builder()
.providerId(userId)
.email(email)
.name(name)
.provider(SocialProvider.GOOGLE)
.build();
} catch (Exception e) {
log.error("Error fetching Google user info with access token", e);
throw new RuntimeException("Failed to fetch Google user info", e);
}
}
@Override
public SocialProvider getProvider() {
return SocialProvider.GOOGLE;
}
}

View File

@@ -4,14 +4,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class ExistLoginIdException extends ErrorResponseException {
public class ExistEmailException extends ErrorResponseException {
public ExistLoginIdException(String loginId) {
public ExistEmailException(String email) {
super(
HttpStatus.CONFLICT,
ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"이미 존재하는 loginId 입니다: " + loginId
"이미 존재하는 email 입니다: " + email
),
null
);

View File

@@ -0,0 +1,25 @@
package com.boilerplate.core.user.api.controller;
import com.boilerplate.core.auth.application.userDetail.User;
import com.boilerplate.core.user.api.dto.res.UserMeResponse;
import com.boilerplate.core.user.application.GetUserMeUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
public class UserController {
private final GetUserMeUseCase getUserMeUseCase;
@GetMapping("/me")
public ResponseEntity<UserMeResponse> getMe(@AuthenticationPrincipal User user) {
UserMeResponse response = getUserMeUseCase.execute(user.getId());
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,13 @@
package com.boilerplate.core.user.api.dto.res;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class UserMeResponse {
private final Long id;
private final String name;
private final String email;
private final String grade;
}

View File

@@ -0,0 +1,34 @@
package com.boilerplate.core.user.application;
import com.boilerplate.core.auth.domain.entity.Account;
import com.boilerplate.core.auth.domain.entity.GradeHistory;
import com.boilerplate.core.auth.domain.repository.AccountRepository;
import com.boilerplate.core.auth.domain.repository.GradeHistoryRepository;
import com.boilerplate.core.exception.common.NotExistEntityException;
import com.boilerplate.core.user.api.dto.res.UserMeResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class GetUserMeUseCase {
private final AccountRepository accountRepository;
private final GradeHistoryRepository gradeHistoryRepository;
public UserMeResponse execute(Long accountId) {
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new NotExistEntityException(Account.class.getName()));
String gradeName = gradeHistoryRepository.findCurrentActiveGrade(accountId)
.map(gh -> gh.getGrade().getName())
.orElse("NORMAL"); // 기본값
return UserMeResponse.builder()
.id(account.getId())
.name(account.getName())
.email(account.getEmail())
.grade(gradeName)
.build();
}
}