Frontend : Next.js 15 (React 19, React-dom 19), Next-Auth
Backend : Spring Boot 3.x.x, Spring Security
dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// Spring Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// JPA (데이터베이스 연동 - 필요에 따라)
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// OAuth 2.0 Client
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// Lombok (선택 사항, 코드 간결화)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// JSON Web Token (JWT)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Google Client Libraries
implementation 'com.google.api-client:google-api-client:1.32.1'
implementation 'com.google.oauth-client:google-oauth-client:1.34.1'
// 데이터베이스 드라이버 (예: H2, MySQL, PostgreSQL)
runtimeOnly 'com.h2database:h2' // 예시로 H2 데이터베이스 사용
}
package com.example.demo.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.List;
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String provider; // 인증 제공자 (google, local 등)
private String providerId; // provider 에서 제공하는 고유 id
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles; // 사용자 역할
}
package com.example.auth.repository;
import com.example.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByProviderAndProviderId(String provider, String providerId);
}
// SecurityConfig.java
package com.example.auth.config;
import com.example.auth.security.JwtAuthenticationFilter;
import com.example.auth.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCryptPasswordEncoder 사용
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// CSRF 비활성화 (JWT 사용 시 불필요)
.csrf((csrf) -> csrf.disable())
// 세션 사용 안함
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/auth/register", "/auth/login", "/auth/google", "/auth/google/callback").permitAll() // 모든 사용자 허용
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// CORS 설정
config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 오리진
config.setAllowedMethods(List.of("GET","POST", "PUT", "DELETE", "PATCH", "OPTIONS")); // 허용할 HTTP 메서드
config.setAllowedHeaders(List.of("*")); // 허용할 헤더
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
package com.example.auth.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long tokenValidTime;
private SecretKey key;
@PostConstruct
public void init(){
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
// JWT 토큰 생성
public String createToken(String email, List<String> roles) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
String email = claims.getSubject();
List<String> roles = (List<String>)claims.get("roles");
List<SimpleGrantedAuthority> authorities = roles.stream().map(SimpleGrantedAuthority::new).toList();
return new UsernamePasswordAuthenticationToken(email, "", authorities);
}
// 토큰에서 이메일 추출
public String getEmailFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// JWT 토큰 유효성 확인
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
package com.example.auth.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
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;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// 헤더에서 토큰 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
package com.example.auth.controller;
import com.example.auth.dto.LoginDto;
import com.example.auth.dto.RegisterDto;
import com.example.auth.entity.User;
import com.example.auth.security.JwtTokenProvider;
import com.example.auth.service.UserService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthenticationController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
@Value("${google.client-id}")
private String googleClientId;
@Value("${google.client-secret}")
private String googleClientSecret;
@Value("${google.redirect-uri}")
private String googleRedirectUri;
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody RegisterDto registerDto) {
User user = User.builder()
.email(registerDto.getEmail())
.password(passwordEncoder.encode(registerDto.getPassword()))
.roles(List.of("ROLE_USER"))
.provider("local")
.build();
userService.register(user);
return new ResponseEntity<>("User registered successfully", HttpStatus.CREATED);
}
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginDto loginDto) {
User user = userService.getUserByEmail(loginDto.getEmail()).orElseThrow(()->new IllegalArgumentException("등록되지 않은 유저입니다."));
if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
String token = jwtTokenProvider.createToken(user.getEmail(), user.getRoles());
return new ResponseEntity<>(token, HttpStatus.OK);
}
@GetMapping("/google")
public void googleLogin(HttpServletResponse response) throws IOException {
String reqUrl = UriComponentsBuilder.fromUriString("https://accounts.google.com/o/oauth2/v2/auth")
.queryParam("client_id", googleClientId)
.queryParam("redirect_uri", googleRedirectUri)
.queryParam("response_type", "code")
.queryParam("scope", "email profile")
.build()
.toUriString();
response.sendRedirect(reqUrl);
}
@GetMapping("/google/callback")
public ResponseEntity<String> googleCallback(@RequestParam String code) throws IOException {
RestTemplate restTemplate = new RestTemplate();
String tokenUrl = UriComponentsBuilder.fromUriString("https://oauth2.googleapis.com/token")
.queryParam("code", code)
.queryParam("client_id", googleClientId)
.queryParam("client_secret", googleClientSecret)
.queryParam("redirect_uri", googleRedirectUri)
.queryParam("grant_type", "authorization_code")
.build()
.toUriString();
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, null, String.class);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode tokenJson = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenJson.get("access_token").asText();
String userInfoUrl = UriComponentsBuilder.fromUriString("https://www.googleapis.com/oauth2/v2/userinfo")
.queryParam("access_token", accessToken)
.build()
.toUriString();
ResponseEntity<String> userInfoResponse = restTemplate.getForEntity(userInfoUrl, String.class);
JsonNode userInfoJson = objectMapper.readTree(userInfoResponse.getBody());
String email = userInfoJson.get("email").asText();
String googleId = userInfoJson.get("id").asText();
User user = userService.getUserByProviderAndProviderId("google", googleId).orElse(null);
if(user == null) {
User newUser = User.builder()
.email(email)
.provider("google")
.providerId(googleId)
.roles(List.of("ROLE_USER"))
.build();
userService.register(newUser);
return new ResponseEntity<>(jwtTokenProvider.createToken(email, newUser.getRoles()), HttpStatus.OK);
}
return new ResponseEntity<>(jwtTokenProvider.createToken(user.getEmail(), user.getRoles()), HttpStatus.OK);
}
}
package com.example.auth.service;
import com.example.auth.entity.User;
import java.util.Optional;
public interface UserService {
void register(User user);
Optional<User> getUserByEmail(String email);
Optional<User> getUserByProviderAndProviderId(String provider, String providerId);
}
// UserServiceImpl.java
package com.example.auth.service;
import com.example.auth.entity.User;
import com.example.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Override
public void register(User user) {
userRepository.save(user);
}
@Override
public Optional<User> getUserByEmail(String email) {
return userRepository.findByEmail(email);
}
@Override
public Optional<User> getUserByProviderAndProviderId(String provider, String providerId) {
return userRepository.findByProviderAndProviderId(provider,providerId);
}
}
package com.example.demo.dto;
import lombok.Data;
@Data
public class SignupDto {
private String email;
private String password;
}
package com.example.demo.dto;
import lombok.Data;
@Data
public class SigninDto {
private String email;
private String password;
}