Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ dependencies {
annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.springframework.boot:spring-boot-configuration-processor'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.umc_study.mission_server.common.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc_study.mission_server.common.response.ApiResponse;
import com.umc_study.mission_server.common.response.GeneralErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

ApiResponse<Void> errorResponse = ApiResponse.error(
GeneralErrorCode.UNAUTHORIZED,
null
);

objectMapper.writeValue(response.getOutputStream(), errorResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.umc_study.mission_server.common.auth;

import com.umc_study.mission_server.member.entity.Member;
import java.util.Collection;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final Member member;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> member.getRole().toString());
}

@Override
public String getPassword() {
return member.getPassword();
}

@Override
public String getUsername() {
return member.getEmail();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.umc_study.mission_server.common.auth;

import com.umc_study.mission_server.member.entity.Member;
import com.umc_study.mission_server.member.exception.MemberErrorCode;
import com.umc_study.mission_server.member.exception.MemberException;
import com.umc_study.mission_server.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(
String username
) throws UsernameNotFoundException {
// 검증할 Member 조회
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND));
// CustomUserDetails 반환
return new CustomUserDetails(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.umc_study.mission_server.common.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc_study.mission_server.common.response.ApiResponse;
import com.umc_study.mission_server.common.response.GeneralErrorCode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 토큰 가져오기
String token = request.getHeader("Authorization");
// token이 없거나 Bearer가 아니면 넘기기
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Bearer이면 추출
token = token.replace("Bearer ", "");
// AccessToken 검증하기: 올바른 토큰이면
if (jwtUtil.isValid(token)) {
// 토큰에서 이메일 추출
String email = jwtUtil.getEmail(token);
// 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성
UserDetails user = customUserDetailsService.loadUserByUsername(email);
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
// 인증 완료 후 SecurityContextHolder에 넣기
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.umc_study.mission_server.common.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.stream.Collectors;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;

@Component
public class JwtUtil {
private final SecretKey secretKey;
private final Duration accessExpiration;

public JwtUtil(
@Value("${jwt.token.secretKey}") String secret,
@Value("${jwt.token.expiration.access}") Long accessExpiration
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = Duration.ofMillis(accessExpiration);
}

// AccessToken 생성
public String createAccessToken(CustomUserDetails user) {
return createToken(user, accessExpiration);
}

/** 토큰에서 이메일 가져오기
*
* @param token 유저 정보를 추출할 토큰
* @return 유저 이메일을 토큰에서 추출합니다
*/
public String getEmail(String token) {
try {
return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기
} catch (JwtException e) {
return null;
}
}

/** 토큰 유효성 확인
*
* @param token 유효한지 확인할 토큰
* @return True, False 반환합니다
*/
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}

// 토큰 생성
private String createToken(CustomUserDetails user, Duration expiration) {
Instant now = Instant.now();

// 인가 정보
String authorities = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));

return Jwts.builder()
.subject(user.getUsername()) // User 이메일을 Subject로
.claim("role", authorities)
.claim("email", user.getUsername())
.issuedAt(Date.from(now)) // 언제 발급한지
.expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지
.signWith(secretKey) // sign할 Key
.compact();
}

// 토큰 정보 가져오기
private Jws<Claims> getClaims(String token) throws JwtException {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.umc_study.mission_server.common.auth;

public enum Role {
ROLE_ADMIN, ROLE_USER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.umc_study.mission_server.common.config;

import com.umc_study.mission_server.common.auth.AuthenticationEntryPointImpl;
import com.umc_study.mission_server.common.auth.CustomUserDetailsService;
import com.umc_study.mission_server.common.auth.JwtAuthFilter;
import com.umc_study.mission_server.common.auth.JwtUtil;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

private final String[] allowUris = {
"/api/members/login",
"/api/members/signup",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requests -> requests
.requestMatchers(allowUris).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf(AbstractHttpConfigurer::disable)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.exceptionHandling(exception -> exception.authenticationEntryPoint(
authenticationEntryPoint()
));

return http.build();
}

@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return new AuthenticationEntryPointImpl();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.umc_study.mission_server.member.controller;

import com.umc_study.mission_server.common.response.ApiResponse;
import com.umc_study.mission_server.member.dto.LoginRequest;
import com.umc_study.mission_server.member.dto.LoginResponse;
import com.umc_study.mission_server.member.dto.MemberResponse;
import com.umc_study.mission_server.member.dto.SignupRequest;
import com.umc_study.mission_server.member.dto.UpdatePreferFoodTypesRequest;
import com.umc_study.mission_server.member.service.AuthService;
import com.umc_study.mission_server.member.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -17,7 +20,7 @@
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {

private final AuthService authService;
private final MemberService memberService;

@PostMapping("/signup")
Expand All @@ -30,4 +33,10 @@ public ApiResponse<?> updatePreferFoods(@RequestBody @Valid UpdatePreferFoodType
memberService.updatePreferFoodTypes(request.memberId(), request.foodTypes());
return ApiResponse.ok(null);
}

@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody @Valid LoginRequest request) {
LoginResponse response = authService.login(request);
return ApiResponse.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.umc_study.mission_server.member.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@Email
String email,
@NotBlank
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.umc_study.mission_server.member.dto;

import lombok.Builder;

@Builder
public record LoginResponse(
Long memberId,
String accessToken
) {
}
Loading