Skip to content
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

Merged
merged 16 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
35 changes: 35 additions & 0 deletions src/main/java/wanted/media/user/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
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 {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 필드 공백 제거해주세요!

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); // 응답과 요청을 다음 필터로 전달
}
}
76 changes: 76 additions & 0 deletions src/main/java/wanted/media/user/config/TokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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;

private long tokenValidTime = 1000L * 60 * 60; // 1시간
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 secret.yml에 같이 넣어도 될 것 같아요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰 만료 기간 말하는 걸까요?

Copy link
Contributor

@pie2457 pie2457 Aug 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네! 그리고 .yml 파일들 전부 노션에 올려주세요 ~

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yml 파일은 노션 문서 페이지 하단에 올려두었습니다. 빈 값들은 각자 로컬 환경에 맞게 넣어서 사용하시면 될 것 같아요!

private long RefreshTokenValidTime = 1000L * 60 * 60 * 24 * 7; // 7일

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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

        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();
    }

메서드 체이닝 방식은 .을 기준으로 내려주시면 가독성이 더 좋은 것 같습니다~!

.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();
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The 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 "))
return header.substring(7);
return null;
}
}
26 changes: 26 additions & 0 deletions src/main/java/wanted/media/user/controller/TokenController.java
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/token")
@RequiredArgsConstructor
public class TokenController {

private final TokenService tokenService;

@PostMapping("/reissue")
public ResponseEntity<TokenResponseDto> reIssueToken(@RequestBody TokenRequestDto requestDto) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reIssue라는 말은 이해가 잘 안가는 것 같아요..! refreshToken을 발급해주는거라면 getRefreshToken이라는 네이밍은 어떠세요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재발급과 관련이 있어서 reIssue라고 이름지었는데 명칭을 바꾸는걸 고려해보겠습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

명칭과 경로 수정했습니다. pr 내용도 수정해놓겠습니다.

TokenResponseDto responseDto = tokenService.reIssueToken(requestDto);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
}
}
11 changes: 11 additions & 0 deletions src/main/java/wanted/media/user/controller/UserController.java
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
Expand All @@ -11,4 +16,10 @@
public class UserController {

private final UserService userService;

@PostMapping("/login")
public ResponseEntity<UserLoginResponseDto> loginUser(@RequestBody UserLoginRequestDto requestDto) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 User라는 말은 굳이 사용하지 않으셔도 괜찮을 것 같아요 ! 로그인 기능이니까 ㅎㅎ

UserLoginResponseDto responseDto = userService.loginUser(requestDto);
return ResponseEntity.ok().body(responseDto);
}
}
10 changes: 10 additions & 0 deletions src/main/java/wanted/media/user/domain/Token.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@ public class Token {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

public Token(String refreshToken, User user) {
this.refreshToken = refreshToken;
this.user = user;
}

public Token updateToken(String refreshToken) {
this.refreshToken = refreshToken;
return this;
}
}
61 changes: 61 additions & 0 deletions src/main/java/wanted/media/user/domain/UserDetail.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 클래스는 뭐하는 클래스 인가요?
사용하지 않는 메서드를 Override할 필요가 없을 것 같은데..!
필드 공백도 제거 해주세요 ㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Security를 이용한 로그인을 구현하는데 있어서 Spring Security에서 사용자 정보를 담는 인터페이스인 UserDetails를 구현할 필요가 있었습니다. 해당 인터페이스의 기본 오버라이드 메서드들을 구현하느라 코드가 길어지게 됐어요. 필드 공백은 제거하도록 하겠습니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 .. 그럼 다른 방법이 없을까요? 기본 메서드를 전부 사용하지 않는데 구현해 놓는 것은 SOLID 원칙에도 위배가 될 것 같아요 !

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

흐음 이거에 대해서 저도 찾아보는 중인데 어쩔 수 없나보네요ㅠㅠ secrurity 너무 어렵네요..!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserDetails 구현을 위해서 메서드들을 사용하지 않더라도 확장성 측면에서 기본적으로 구현하는 것이 낫겠다고 판단해서 메서드들을 구현해놨는데요, 이 부분은 다른 방법을 생각해보지 못해서 고민해보겠습니다.


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;
}
}
14 changes: 14 additions & 0 deletions src/main/java/wanted/media/user/dto/TokenRequestDto.java
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 TokenRequestDto {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거는 record 클래스 알아보시고 사용하시면 될 것 같아요~

public record TokenRequestDto(String accessToken, String refreshToken) {
}

이렇게 간결하게 만들어줄 수 있습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 한번 찾아보겠습니다!

private String accessToken;
private String refreshToken;
}
14 changes: 14 additions & 0 deletions src/main/java/wanted/media/user/dto/TokenResponseDto.java
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;
}
15 changes: 15 additions & 0 deletions src/main/java/wanted/media/user/dto/UserLoginRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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 {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 필드 공백 제거해주세요!

private String account;
private String password;
}
16 changes: 16 additions & 0 deletions src/main/java/wanted/media/user/dto/UserLoginResponseDto.java
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;
}
14 changes: 14 additions & 0 deletions src/main/java/wanted/media/user/repository/TokenRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package wanted.media.user.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
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> {
@Query("SELECT t FROM Token t WHERE t.user.userId = :userId")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기는 Query를 작성하신 이유가 있나용?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메서드 네이밍 문제인지 회원고유식별값으로 해당 회원의 토큰을 찾는 동작을 제대로 수행하지 못해 쿼리를 작성하게 됐습니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 userId를 못찾아오는지 확인해보셔도 될 것 같아요 원래라면 되야 하는 메서드니까~!

Optional<Token> findByUserId(@Param("userId") UUID userID);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Param 제거하시고 userId로 변경하시면 될 것 같습니당

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import org.springframework.data.jpa.repository.JpaRepository;
import wanted.media.user.domain.User;

import java.util.Optional;
import java.util.UUID;

public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByAccount(String account);
}
Loading