This commit is contained in:
ondal
2025-02-12 21:24:01 +09:00
commit 7a4f60c842
222 changed files with 3018 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
}
bootJar {
archiveFileName = "member.jar"
}
@@ -0,0 +1,31 @@
server:
port: ${SERVER_PORT:8081}
spring:
application:
name: member-service
datasource:
url: ${POSTGRES_URL}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
jwt:
secret-key: ${JWT_SECRET_KEY}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
allowedorigins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
@@ -0,0 +1,11 @@
package com.unicorn.lifesub.member;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MemberApplication {
public static void main(String[] args) {
SpringApplication.run(MemberApplication.class, args);
}
}
@@ -0,0 +1,44 @@
// File: lifesub/member/src/main/java/com/unicorn/lifesub/member/config/InitialDataLoader.java
package com.unicorn.lifesub.member.config;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
import com.unicorn.lifesub.member.repository.jpa.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.IntStream;
@Component
@RequiredArgsConstructor
public class InitialDataLoader implements CommandLineRunner {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public void run(String... args) {
// 기존 사용자 데이터가 없을 경우에만 초기 데이터 생성
if (memberRepository.count() == 0) {
Set<String> userRoles = new HashSet<>();
userRoles.add("USER");
String encodedPassword = passwordEncoder.encode("P@ssw0rd$");
IntStream.rangeClosed(1, 10).forEach(i -> {
String userId = String.format("user%02d", i);
MemberEntity member = MemberEntity.builder()
.userId(userId)
.userName("사용자" + i)
.password(encodedPassword)
.roles(userRoles)
.build();
memberRepository.save(member);
});
}
}
}
@@ -0,0 +1,86 @@
package com.unicorn.lifesub.member.config;
import com.unicorn.lifesub.member.config.jwt.CustomUserDetailsService;
import com.unicorn.lifesub.member.config.jwt.JwtTokenProvider;
import com.unicorn.lifesub.member.config.jwt.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.configurers.AbstractHttpConfigurer;
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.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
@SuppressWarnings("unused")
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService customUserDetailsService;
@Value("${allowedorigins}")
private String allowedOrigins;
public SecurityConfig(JwtTokenProvider jwtTokenProvider, CustomUserDetailsService customUserDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.customUserDetailsService = customUserDetailsService;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors
.configurationSource(corsConfigurationSource())
)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.userDetailsService(customUserDetailsService)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,28 @@
package com.unicorn.lifesub.member.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@SuppressWarnings("unused")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("회원 서비스 API")
.version("v1.0.0")
.description("회원 서비스 API 명세서입니다."));
}
}
@@ -0,0 +1,41 @@
package com.unicorn.lifesub.member.config.jwt;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
import com.unicorn.lifesub.member.repository.jpa.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
MemberEntity member = memberRepository.findByUserId(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found with userId: " + userId));
Set<GrantedAuthority> authorities = member.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
return User.builder()
.username(member.getUserId())
.password(member.getPassword())
.authorities(authorities)
.build();
}
}
@@ -0,0 +1,36 @@
package com.unicorn.lifesub.member.config.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token) == 1) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,148 @@
package com.unicorn.lifesub.member.config.jwt;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.InvalidClaimException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.unicorn.lifesub.common.exception.ErrorCode;
import com.unicorn.lifesub.common.dto.JwtTokenDTO;
import com.unicorn.lifesub.common.exception.InfraException;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
private final Algorithm algorithm;
private final long accessTokenValidityInMilliseconds;
private final long refreshTokenValidityInMilliseconds;
public JwtTokenProvider(
@Value("${jwt.secret-key}") String secretKey,
@Value("${jwt.access-token-validity}") long accessTokenValidityInMilliseconds,
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityInMilliseconds) {
this.algorithm = Algorithm.HMAC512(secretKey);
this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds * 1000;
this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds * 1000;
}
public JwtTokenDTO createToken(MemberEntity memberEntity, Collection<? extends GrantedAuthority> authorities) {
try {
Date now = new Date();
Date accessTokenValidity = new Date(now.getTime() + accessTokenValidityInMilliseconds);
Date refreshTokenValidity = new Date(now.getTime() + refreshTokenValidityInMilliseconds);
String accessToken = JWT.create()
.withSubject(memberEntity.getUserId())
.withClaim("userId", memberEntity.getUserId())
.withClaim("userName", memberEntity.getUserName())
.withClaim("auth", authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
.withIssuedAt(now)
.withExpiresAt(accessTokenValidity)
.sign(algorithm);
String refreshToken = JWT.create()
.withSubject(memberEntity.getUserId())
.withIssuedAt(now)
.withExpiresAt(refreshTokenValidity)
.sign(algorithm);
return JwtTokenDTO.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
} catch(Exception e) {
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
}
}
public boolean validateRefreshToken(String refreshToken) {
try {
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(refreshToken);
return true;
} catch (Exception e) {
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
}
}
public String getUserIdFromToken(String refreshToken) {
try {
DecodedJWT decodedJWT = JWT.decode(refreshToken);
return decodedJWT.getSubject();
} catch (Exception e) {
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
}
}
public String resolveToken(HttpServletRequest request) {
try {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
} catch (Exception e) {
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
}
}
public int validateToken(String token) {
log.info("******** validateToken: {}", token);
try {
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
return 1; // 검사 성공 시 1 반환
} catch (TokenExpiredException e) {
log.error("Token validation failed: {}", e.getMessage(), e);
throw new InfraException(ErrorCode.TOKEN_EXPIRED);
} catch (SignatureVerificationException e) {
log.error("Token validation failed: {}", e.getMessage(), e);
throw new InfraException(ErrorCode.SIGNATURE_VERIFICATION_EXCEPTION);
} catch (AlgorithmMismatchException e) {
log.error("AlgorithmMismatchException: {}", e.getMessage(), e);
throw new InfraException(ErrorCode.ALGORITHM_MISMATCH_EXCEPTION);
} catch (InvalidClaimException e) {
log.error("InvalidClaimException: {}", e.getMessage(), e);
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
} catch (Exception e) {
log.error("Undefined Error: {}", e.getMessage(), e);
throw new InfraException(ErrorCode.UNDIFINED_ERROR);
}
}
public Authentication getAuthentication(String token) {
try {
DecodedJWT decodedJWT = JWT.decode(token);
String username = decodedJWT.getSubject();
String[] authStrings = decodedJWT.getClaim("auth").asArray(String.class);
Collection<? extends GrantedAuthority> authorities = Arrays.stream(authStrings)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails userDetails = new User(username, "", authorities);
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
} catch (Exception e) {
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
}
}
}
@@ -0,0 +1,37 @@
package com.unicorn.lifesub.member.controller;
import com.unicorn.lifesub.common.dto.ApiResponse;
import com.unicorn.lifesub.common.dto.JwtTokenDTO;
import com.unicorn.lifesub.member.dto.*;
import com.unicorn.lifesub.member.service.MemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
@Tag(name = "회원 API", description = "회원 인증 관련 API")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@Operation(summary = "로그인", description = "사용자 ID와 비밀번호로 로그인합니다.")
@PostMapping("/login")
public ResponseEntity<ApiResponse<JwtTokenDTO>> login(@Valid @RequestBody LoginRequest request) {
JwtTokenDTO response = memberService.login(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
@PostMapping("/logout")
public ResponseEntity<ApiResponse<LogoutResponse>> logout(@Valid @RequestBody LogoutRequest request) {
LogoutResponse response = memberService.logout(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,21 @@
package com.unicorn.lifesub.member.domain;
import lombok.Builder;
import lombok.Getter;
import java.util.Set;
@Getter
public class Member {
private final String userId;
private final String userName;
private final String password;
private final Set<String> roles;
@Builder
public Member(String userId, String userName, String password, Set<String> roles) {
this.userId = userId;
this.userName = userName;
this.password = password;
this.roles = roles;
}
}
@@ -0,0 +1,13 @@
package com.unicorn.lifesub.member.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class LoginRequest {
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
}
@@ -0,0 +1,10 @@
package com.unicorn.lifesub.member.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class LogoutRequest {
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;
}
@@ -0,0 +1,10 @@
package com.unicorn.lifesub.member.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class LogoutResponse {
private String message;
}
@@ -0,0 +1,54 @@
package com.unicorn.lifesub.member.repository.entity;
import com.unicorn.lifesub.common.entity.BaseTimeEntity;
import com.unicorn.lifesub.member.domain.Member;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "members")
@Getter
@NoArgsConstructor
public class MemberEntity extends BaseTimeEntity {
@Id
private String userId;
private String userName;
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "member_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
@Builder
public MemberEntity(String userId, String userName, String password, Set<String> roles) {
this.userId = userId;
this.userName = userName;
this.password = password;
this.roles = roles;
}
public Member toDomain() {
return Member.builder()
.userId(userId)
.userName(userName)
.password(password)
.roles(roles)
.build();
}
public static MemberEntity fromDomain(Member member) {
return MemberEntity.builder()
.userId(member.getUserId())
.userName(member.getUserName())
.password(member.getPassword())
.roles(member.getRoles())
.build();
}
}
@@ -0,0 +1,9 @@
package com.unicorn.lifesub.member.repository.jpa;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<MemberEntity, String> {
Optional<MemberEntity> findByUserId(String userId);
}
@@ -0,0 +1,9 @@
package com.unicorn.lifesub.member.service;
import com.unicorn.lifesub.common.dto.JwtTokenDTO;
import com.unicorn.lifesub.member.dto.*;
public interface MemberService {
JwtTokenDTO login(LoginRequest request);
LogoutResponse logout(LogoutRequest request);
}
@@ -0,0 +1,56 @@
package com.unicorn.lifesub.member.service;
import com.unicorn.lifesub.common.exception.BusinessException;
import com.unicorn.lifesub.common.exception.ErrorCode;
import com.unicorn.lifesub.member.config.jwt.JwtTokenProvider;
import com.unicorn.lifesub.common.dto.JwtTokenDTO;
import com.unicorn.lifesub.member.dto.LoginRequest;
import com.unicorn.lifesub.member.dto.LogoutRequest;
import com.unicorn.lifesub.member.dto.LogoutResponse;
import com.unicorn.lifesub.common.exception.InfraException;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
import com.unicorn.lifesub.member.repository.jpa.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
@Override
@Transactional(readOnly = true)
public JwtTokenDTO login(LoginRequest request) {
MemberEntity member = memberRepository.findByUserId(request.getUserId())
.orElseThrow(() -> new InfraException(ErrorCode.MEMBER_NOT_FOUND));
// 사용자의 권한 정보 생성
Collection<? extends GrantedAuthority> authorities = member.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}
return jwtTokenProvider.createToken(member, authorities);
}
@Override
@Transactional
public LogoutResponse logout(LogoutRequest request) {
// 실제 구현에서는 Redis 등을 사용하여 토큰 블랙리스트 관리
return LogoutResponse.builder()
.message("로그아웃이 완료되었습니다.")
.build();
}
}
+32
View File
@@ -0,0 +1,32 @@
server:
port: ${SERVER_PORT:8081}
spring:
application:
name: member-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:member}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
jwt:
secret-key: ${JWT_SECRET_KEY}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
allowedorigins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs