-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[feat] 사용자 로그인 기능 구현 #22
Changes from 13 commits
4e3a3ea
99ebf13
3765657
d888f83
848ce30
979198c
e2ff106
9455bed
e21ab02
382d032
720d726
6791477
e330442
ca15823
7f57a62
4d40bbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package wanted.media.exception; | ||
|
||
public class NotFoundException extends RuntimeException { | ||
public NotFoundException(String message) { | ||
super(message); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,24 @@ | ||
package wanted.media.exception.handler; | ||
|
||
import org.apache.coyote.BadRequestException; | ||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.ExceptionHandler; | ||
import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
||
import wanted.media.exception.ErrorResponse; | ||
import wanted.media.exception.NotFoundException; | ||
|
||
@RestControllerAdvice | ||
public class GlobalExceptionHandler { | ||
|
||
@ExceptionHandler(BadRequestException.class) | ||
public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestException e) { | ||
return ResponseEntity.badRequest() | ||
.body(new ErrorResponse(400, e.getMessage())); | ||
} | ||
@ExceptionHandler(BadRequestException.class) | ||
public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestException e) { | ||
return ResponseEntity.badRequest() | ||
.body(new ErrorResponse(400, e.getMessage())); | ||
} | ||
|
||
@ExceptionHandler(NotFoundException.class) | ||
public ResponseEntity<String> handleNotFoundException(NotFoundException e) { | ||
return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,41 @@ | ||
package wanted.media.user.config; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.http.HttpStatus; | ||
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.web.SecurityFilterChain; | ||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
|
||
@Configuration | ||
@EnableWebSecurity | ||
@RequiredArgsConstructor | ||
public class SecurityConfig { | ||
private final TokenProvider tokenProvider; | ||
|
||
@Bean | ||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | ||
http | ||
.httpBasic(AbstractHttpConfigurer::disable) | ||
.csrf(AbstractHttpConfigurer::disable) | ||
.formLogin(AbstractHttpConfigurer::disable) | ||
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
.authorizeHttpRequests((auth) -> auth | ||
.requestMatchers("/user/**").permitAll() // 어떤 사용자든 접근 가능 | ||
.anyRequest().authenticated()) | ||
.exceptionHandling(e -> e | ||
.authenticationEntryPoint((request, response, exception) -> { | ||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "인증이 필요합니다."); | ||
}) | ||
.accessDeniedHandler((request, response, exception) -> { | ||
response.sendError(HttpStatus.FORBIDDEN.value(), "접근권한이 없습니다."); | ||
})) | ||
.addFilterBefore(new TokenAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); | ||
|
||
return http.build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
package wanted.media.user.config; | ||
|
||
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.web.filter.OncePerRequestFilter; | ||
|
||
import java.io.IOException; | ||
|
||
@RequiredArgsConstructor | ||
public class TokenAuthenticationFilter extends OncePerRequestFilter { | ||
|
||
private final TokenProvider tokenProvider; | ||
|
||
@Override | ||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { | ||
String token = tokenProvider.resolveToken(request); // 헤더에서 가져온 액세스토큰 | ||
// 토큰 유효성 검증 | ||
if (token != null && tokenProvider.validToken(token)) { | ||
Authentication authentication = tokenProvider.getAuthentication(token); // 인증 정보 | ||
SecurityContextHolder.getContext().setAuthentication(authentication); // 인증 정보를 보안 컨텍스트에 설정 | ||
} | ||
filterChain.doFilter(request, response); // 응답과 요청을 다음 필터로 전달 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
package wanted.media.user.config; | ||
|
||
import io.jsonwebtoken.ExpiredJwtException; | ||
import io.jsonwebtoken.Header; | ||
import io.jsonwebtoken.Jwts; | ||
import io.jsonwebtoken.SignatureAlgorithm; | ||
import jakarta.servlet.http.HttpServletRequest; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
import org.springframework.security.core.Authentication; | ||
import org.springframework.stereotype.Component; | ||
import wanted.media.user.domain.UserDetail; | ||
import wanted.media.user.service.UserDetailService; | ||
|
||
import java.util.Date; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class TokenProvider { | ||
|
||
@Value("${jwt.secret_key}") | ||
private String key; | ||
@Value("${jwt.access_token_expiration}") | ||
private long tokenValidTime; | ||
@Value("${jwt.refresh_token_expiration}") | ||
private long RefreshTokenValidTime; | ||
private final String BEARER_PREFIX = "Bearer "; | ||
|
||
private final UserDetailService userDetailService; | ||
|
||
public String makeToken(String account, String type) { | ||
Date now = new Date(); | ||
long time = type.equals("access") ? tokenValidTime : RefreshTokenValidTime; | ||
|
||
return Jwts.builder() | ||
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 타입 | ||
.setClaims(Jwts.claims(). | ||
setSubject(account). | ||
setAudience(type). | ||
setIssuedAt(now). | ||
setExpiration(new Date(now.getTime() + time)) | ||
) | ||
.signWith(SignatureAlgorithm.HS256, key) // HS256 방식으로 key와 함께 암호화 | ||
.compact(); | ||
} | ||
|
||
// 토큰 유효성 검증 | ||
public boolean validToken(String token) { | ||
try { | ||
Jwts.parser().setSigningKey(key) | ||
.parseClaimsJws(token); | ||
return true; | ||
} catch (Exception e) { | ||
return false; | ||
} | ||
} | ||
|
||
// 토큰으로 인증 정보 담은 Authentication 반환 | ||
public Authentication getAuthentication(String token) { | ||
UserDetail userDetail = (UserDetail) userDetailService.loadUserByUsername(getUserAccount(token)); | ||
return new UsernamePasswordAuthenticationToken(userDetail, "", userDetail.getAuthorities()); | ||
/* principal : 인증된 사용자 정보 | ||
credentials : 사용자의 인증 자격 증명 (인증 완료된 상태이므로 빈 문자열 사용) | ||
authorities : 사용자의 권한목록*/ | ||
} | ||
|
||
public String getUserAccount(String token) { | ||
try { // JWT를 파싱해서 JWT 서명 검증 후 클레임을 반환하여 payload에서 subject 클레임 추출 | ||
return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody().getSubject(); | ||
} catch (ExpiredJwtException e) { | ||
return e.getClaims().getSubject(); | ||
} | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 7같은 매직넘버보다는 위의 "Bearer "를 BEARER_PREFIX같은 상수로 빼서 .length()를 사용하셔도 좋을 것 같아요! |
||
// 토큰 Header에서 꺼내오기 | ||
public String resolveToken(HttpServletRequest request) { | ||
String header = request.getHeader("Authorization"); | ||
if (header != null && header.startsWith(BEARER_PREFIX)) | ||
return header.substring(BEARER_PREFIX.length()); | ||
return null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package wanted.media.user.controller; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.http.HttpStatus; | ||
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; | ||
import wanted.media.user.dto.TokenRequestDto; | ||
import wanted.media.user.dto.TokenResponseDto; | ||
import wanted.media.user.service.TokenService; | ||
|
||
@RestController | ||
@RequestMapping("/api") | ||
@RequiredArgsConstructor | ||
public class TokenController { | ||
|
||
private final TokenService tokenService; | ||
|
||
@PostMapping("/token") | ||
public ResponseEntity<TokenResponseDto> getToken(@RequestBody TokenRequestDto requestDto) { | ||
TokenResponseDto responseDto = tokenService.getToken(requestDto); | ||
return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,13 @@ | ||
package wanted.media.user.controller; | ||
|
||
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; | ||
import wanted.media.user.dto.UserLoginRequestDto; | ||
import wanted.media.user.dto.UserLoginResponseDto; | ||
import wanted.media.user.service.UserService; | ||
|
||
@RestController | ||
|
@@ -11,4 +16,10 @@ | |
public class UserController { | ||
|
||
private final UserService userService; | ||
|
||
@PostMapping("/login") | ||
public ResponseEntity<UserLoginResponseDto> loginUser(@RequestBody UserLoginRequestDto requestDto) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기도 User라는 말은 굳이 사용하지 않으셔도 괜찮을 것 같아요 ! 로그인 기능이니까 ㅎㅎ |
||
UserLoginResponseDto responseDto = userService.login(requestDto); | ||
return ResponseEntity.ok().body(responseDto); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
package wanted.media.user.domain; | ||
|
||
import lombok.Builder; | ||
import org.springframework.security.core.GrantedAuthority; | ||
import org.springframework.security.core.userdetails.UserDetails; | ||
|
||
import java.util.Collection; | ||
import java.util.List; | ||
|
||
public class UserDetail implements UserDetails { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 클래스는 뭐하는 클래스 인가요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spring Security를 이용한 로그인을 구현하는데 있어서 Spring Security에서 사용자 정보를 담는 인터페이스인 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 음 .. 그럼 다른 방법이 없을까요? 기본 메서드를 전부 사용하지 않는데 구현해 놓는 것은 SOLID 원칙에도 위배가 될 것 같아요 ! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 흐음 이거에 대해서 저도 찾아보는 중인데 어쩔 수 없나보네요ㅠㅠ secrurity 너무 어렵네요..! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
private String account; | ||
private String password; | ||
private List<GrantedAuthority> authorities; | ||
|
||
@Builder | ||
public UserDetail(String account, String password, List<GrantedAuthority> authorities) { | ||
this.account = account; | ||
this.password = password; | ||
this.authorities = authorities; | ||
} | ||
|
||
@Override | ||
public Collection<? extends GrantedAuthority> getAuthorities() { | ||
return authorities; | ||
} | ||
|
||
@Override | ||
public String getPassword() { | ||
return password; | ||
} | ||
|
||
@Override | ||
public String getUsername() { | ||
return account; | ||
} | ||
|
||
// 계정 만료 여부 | ||
@Override | ||
public boolean isAccountNonExpired() { | ||
return true; | ||
} | ||
|
||
// 계정 잠금 여부 | ||
@Override | ||
public boolean isAccountNonLocked() { | ||
return true; | ||
} | ||
|
||
// 비밀번호 만료 여부 | ||
@Override | ||
public boolean isCredentialsNonExpired() { | ||
return true; | ||
} | ||
|
||
// 계정 사용 가능 여부 | ||
@Override | ||
public boolean isEnabled() { | ||
return true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
package wanted.media.user.dto; | ||
|
||
public record TokenRequestDto(String accessToken, String refreshToken) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package wanted.media.user.dto; | ||
|
||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Getter | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@AllArgsConstructor | ||
public class TokenResponseDto { | ||
private String accessToken; | ||
private String refreshToken; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package wanted.media.user.dto; | ||
|
||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Getter | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@AllArgsConstructor | ||
public class UserLoginRequestDto { | ||
private String account; | ||
private String password; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package wanted.media.user.dto; | ||
|
||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
import java.util.UUID; | ||
|
||
@Getter | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@AllArgsConstructor | ||
public class UserLoginResponseDto { | ||
private UUID userId; | ||
private String token; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package wanted.media.user.repository; | ||
|
||
import org.springframework.data.jpa.repository.JpaRepository; | ||
import org.springframework.data.repository.query.Param; | ||
import wanted.media.user.domain.Token; | ||
|
||
import java.util.Optional; | ||
import java.util.UUID; | ||
|
||
public interface TokenRepository extends JpaRepository<Token, Long> { | ||
Optional<Token> findByUser_UserId(@Param("userId") UUID userId); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
메서드 체이닝 방식은 .을 기준으로 내려주시면 가독성이 더 좋은 것 같습니다~!