feat(auth): JWT 기반 Google 로그인 인증 API 구현

- Entity: User, Role, RoleUrlPattern, UserStatus enum
- Repository: UserRepository, RoleRepository (fetch join 쿼리)
- Auth: GoogleTokenVerifier, JwtTokenProvider, JwtAuthenticationFilter
- API: POST /api/auth/google, GET /api/auth/me, POST /api/auth/logout
- DTO: AuthResponse, UserResponse, RoleResponse, GoogleLoginRequest
- SecurityConfig: JWT 필터 등록, CORS 설정, 공개 엔드포인트 정의
- 초기 데이터: roles + role_url_patterns 시드 (data.sql)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-14 17:28:51 +09:00
부모 9e4ea79867
커밋 ef667db990
17개의 변경된 파일651개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -0,0 +1,96 @@
package com.gcsc.guide.auth;
import com.gcsc.guide.dto.AuthResponse;
import com.gcsc.guide.dto.GoogleLoginRequest;
import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.repository.UserRepository;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr";
private final GoogleTokenVerifier googleTokenVerifier;
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
/**
* Google ID Token으로 로그인/회원가입 처리 JWT 발급
*/
@PostMapping("/google")
public ResponseEntity<AuthResponse> googleLogin(@Valid @RequestBody GoogleLoginRequest request) {
GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken());
if (payload == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다");
}
String email = payload.getEmail();
String name = (String) payload.get("name");
String avatarUrl = (String) payload.get("picture");
userRepository.findByEmail(email)
.ifPresentOrElse(
existingUser -> {
existingUser.updateProfile(name, avatarUrl);
existingUser.updateLastLogin();
userRepository.save(existingUser);
},
() -> createNewUser(email, name, avatarUrl)
);
User userWithRoles = userRepository.findByEmailWithRoles(email)
.orElseThrow();
String token = jwtTokenProvider.generateToken(
userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin());
return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles)));
}
/**
* 현재 인증된 사용자 정보 조회
*/
@GetMapping("/me")
public ResponseEntity<UserResponse> getCurrentUser(Authentication authentication) {
Long userId = (Long) authentication.getPrincipal();
User user = userRepository.findByIdWithRoles(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"));
return ResponseEntity.ok(UserResponse.from(user));
}
/**
* 로그아웃 (Stateless JWT이므로 서버 처리 없음, 프론트에서 토큰 삭제)
*/
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
return ResponseEntity.noContent().build();
}
private User createNewUser(String email, String name, String avatarUrl) {
User newUser = new User(email, name, avatarUrl);
if (AUTO_ADMIN_EMAIL.equals(email)) {
newUser.activate();
newUser.grantAdmin();
log.info("관리자 자동 승인: {}", email);
}
newUser.updateLastLogin();
return userRepository.save(newUser);
}
}

파일 보기

@ -0,0 +1,57 @@
package com.gcsc.guide.auth;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Collections;
@Slf4j
@Component
public class GoogleTokenVerifier {
private final GoogleIdTokenVerifier verifier;
private final String allowedEmailDomain;
public GoogleTokenVerifier(
@Value("${app.google.client-id}") String clientId,
@Value("${app.allowed-email-domain}") String allowedEmailDomain
) {
this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Collections.singletonList(clientId))
.build();
this.allowedEmailDomain = allowedEmailDomain;
}
/**
* Google ID Token을 검증하고 페이로드를 반환한다.
* 검증 실패 또는 허용되지 않은 이메일 도메인이면 null을 반환한다.
*/
public GoogleIdToken.Payload verify(String idTokenString) {
try {
GoogleIdToken idToken = verifier.verify(idTokenString);
if (idToken == null) {
log.warn("Google ID Token 검증 실패: 유효하지 않은 토큰");
return null;
}
GoogleIdToken.Payload payload = idToken.getPayload();
String email = payload.getEmail();
if (email == null || !email.endsWith("@" + allowedEmailDomain)) {
log.warn("허용되지 않은 이메일 도메인: {}", email);
return null;
}
return payload;
} catch (Exception e) {
log.error("Google ID Token 검증 중 오류: {}", e.getMessage());
return null;
}
}
}

파일 보기

@ -0,0 +1,57 @@
package com.gcsc.guide.auth;
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.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Long userId = jwtTokenProvider.getUserIdFromToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, token, List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}

파일 보기

@ -0,0 +1,66 @@
package com.gcsc.guide.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expirationMs;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms}") long expirationMs
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
public String generateToken(Long userId, String email, boolean isAdmin) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.subject(userId.toString())
.claim("email", email)
.claim("isAdmin", isAdmin)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 토큰 검증 실패: {}", e.getMessage());
return false;
}
}
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

파일 보기

@ -1,28 +1,65 @@
package com.gcsc.guide.config; package com.gcsc.guide.config;
import com.gcsc.guide.auth.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}")
private List<String> allowedOrigins;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(session -> .sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health", "/h2-console/**").permitAll() .requestMatchers(
"/api/auth/**",
"/api/health",
"/actuator/health",
"/h2-console/**"
).permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
} }

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.guide.dto;
public record AuthResponse(
String token,
UserResponse user
) {
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record GoogleLoginRequest(
@NotBlank String idToken
) {
}

파일 보기

@ -0,0 +1,27 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.RoleUrlPattern;
import java.util.List;
public record RoleResponse(
Long id,
String name,
String description,
List<String> urlPatterns
) {
public static RoleResponse from(Role role) {
List<String> patterns = role.getUrlPatterns().stream()
.map(RoleUrlPattern::getUrlPattern)
.toList();
return new RoleResponse(
role.getId(),
role.getName(),
role.getDescription(),
patterns
);
}
}

파일 보기

@ -0,0 +1,37 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.User;
import java.time.LocalDateTime;
import java.util.List;
public record UserResponse(
Long id,
String email,
String name,
String avatarUrl,
String status,
boolean isAdmin,
List<RoleResponse> roles,
LocalDateTime createdAt,
LocalDateTime lastLoginAt
) {
public static UserResponse from(User user) {
List<RoleResponse> roles = user.getRoles().stream()
.map(RoleResponse::from)
.toList();
return new UserResponse(
user.getId(),
user.getEmail(),
user.getName(),
user.getAvatarUrl(),
user.getStatus().name(),
user.isAdmin(),
roles,
user.getCreatedAt(),
user.getLastLoginAt()
);
}
}

파일 보기

@ -0,0 +1,47 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "roles")
@Getter
@NoArgsConstructor
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
@Column(length = 255)
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@OneToMany(mappedBy = "role", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<RoleUrlPattern> urlPatterns = new ArrayList<>();
public Role(String name, String description) {
this.name = name;
this.description = description;
}
public void update(String name, String description) {
this.name = name;
this.description = description;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,38 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "role_url_patterns")
@Getter
@NoArgsConstructor
public class RoleUrlPattern {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(name = "url_pattern", nullable = false, length = 255)
private String urlPattern;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public RoleUrlPattern(Role role, String urlPattern) {
this.role = role;
this.urlPattern = urlPattern;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,103 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false, length = 100)
private String name;
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private UserStatus status = UserStatus.PENDING;
@Column(name = "is_admin", nullable = false)
private boolean isAdmin = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
public User(String email, String name, String avatarUrl) {
this.email = email;
this.name = name;
this.avatarUrl = avatarUrl;
}
public void activate() {
this.status = UserStatus.ACTIVE;
}
public void reject() {
this.status = UserStatus.REJECTED;
}
public void disable() {
this.status = UserStatus.DISABLED;
}
public void grantAdmin() {
this.isAdmin = true;
}
public void revokeAdmin() {
this.isAdmin = false;
}
public void updateLastLogin() {
this.lastLoginAt = LocalDateTime.now();
}
public void updateProfile(String name, String avatarUrl) {
this.name = name;
this.avatarUrl = avatarUrl;
}
public void updateRoles(Set<Role> roles) {
this.roles = roles;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.guide.entity;
public enum UserStatus {
PENDING,
ACTIVE,
REJECTED,
DISABLED
}

파일 보기

@ -0,0 +1,19 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(String name);
@Query("SELECT DISTINCT r FROM Role r LEFT JOIN FETCH r.urlPatterns")
List<Role> findAllWithUrlPatterns();
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id")
Optional<Role> findByIdWithUrlPatterns(Long id);
}

파일 보기

@ -0,0 +1,24 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.entity.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.id = :id")
Optional<User> findByIdWithRoles(Long id);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.email = :email")
Optional<User> findByEmailWithRoles(String email);
List<User> findByStatus(UserStatus status);
long countByStatus(UserStatus status);
}

파일 보기

@ -8,10 +8,15 @@ spring:
jpa: jpa:
open-in-view: false open-in-view: false
defer-datasource-initialization: true
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
jackson:
serialization:
write-dates-as-timestamps: false
server: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
@ -23,6 +28,8 @@ app:
google: google:
client-id: ${GOOGLE_CLIENT_ID:} client-id: ${GOOGLE_CLIENT_ID:}
allowed-email-domain: gcsc.co.kr allowed-email-domain: gcsc.co.kr
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev}
# Actuator # Actuator
management: management:

파일 보기

@ -0,0 +1,11 @@
-- 초기 롤 시드 데이터
INSERT INTO roles (name, description, created_at) VALUES
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()),
('DEVELOPER', '전체 개발 가이드 접근', NOW()),
('FRONT_DEV', '프론트엔드 개발 가이드만', NOW());
-- 롤별 URL 패턴
INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES
((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()),
((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()),
((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW());