Skip to content

Commit

Permalink
Merge pull request #22 from 2024-pre-onboarding-backend-F/feat/login
Browse files Browse the repository at this point in the history
[feat] 사용자 로그인 기능 구현
  • Loading branch information
jw427 authored Aug 26, 2024
2 parents 5e16349 + 4d40bbb commit c78da78
Show file tree
Hide file tree
Showing 19 changed files with 466 additions and 50 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand Down
60 changes: 29 additions & 31 deletions src/main/java/wanted/media/content/repository/StatRepository.java
Original file line number Diff line number Diff line change
@@ -1,43 +1,41 @@
package wanted.media.content.repository;

import static com.querydsl.core.types.ExpressionUtils.*;
import static wanted.media.content.domain.QPost.*;
import static wanted.media.user.domain.QUser.*;

import org.springframework.stereotype.Repository;

import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import wanted.media.content.domain.dto.StatParam;

import static com.querydsl.core.types.ExpressionUtils.count;
import static wanted.media.content.domain.QPost.*;
import static wanted.media.user.domain.QUser.user;

@Repository
@RequiredArgsConstructor
public class StatRepository {
private final JPAQueryFactory queryFactory;
private final JPAQueryFactory queryFactory;

/**
* ex.
* SELECT SUM(view_count)
* FROM post p
* LEFT JOIN members m ON p.user_id = m.user_id
* where p.created_at between '2024-08-18 00:00:00' and '2024-08-25 23:59:59'
* and m.account = 'user1';
*/
public Long statistics(StatParam param) {
var selectQuery = switch (param.value()) {
case COUNT -> queryFactory.select(count(post.id));
case LIKE_COUNT -> queryFactory.select(post.likeCount.sum());
case VIEW_COUNT -> queryFactory.select(post.viewCount.sum());
case SHARE_COUNT -> queryFactory.select(post.shareCount.sum());
};
/**
* ex.
* SELECT SUM(view_count)
* FROM post p
* LEFT JOIN members m ON p.user_id = m.user_id
* where p.created_at between '2024-08-18 00:00:00' and '2024-08-25 23:59:59'
* and m.account = 'user1';
*/
public Long statistics(StatParam param) {
var selectQuery = switch (param.value()) {
case COUNT -> queryFactory.select(count(post.id));
case LIKE_COUNT -> queryFactory.select(post.likeCount.sum());
case VIEW_COUNT -> queryFactory.select(post.viewCount.sum());
case SHARE_COUNT -> queryFactory.select(post.shareCount.sum());
};

return selectQuery.from(post)
.leftJoin(user).on(post.user.userId.eq(user.userId))
.where(
post.createdAt.between(param.start(), param.end()),
post.user.account.eq(param.hashtag())
)
.fetchFirst();
}
return selectQuery.from(post)
.leftJoin(user).on(post.user.userId.eq(user.userId))
.where(
post.createdAt.between(param.start(), param.end()),
post.user.account.eq(param.hashtag())
)
.fetchFirst();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
@RequiredArgsConstructor
public class NotFoundException extends RuntimeException {
private final ErrorCode errorCode;
}
}
34 changes: 33 additions & 1 deletion src/main/java/wanted/media/user/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;

// 비밀번호 암호화 기능
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@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); // 응답과 요청을 다음 필터로 전달
}
}
83 changes: 83 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,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();
}
}

// 토큰 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;
}
}
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")
@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
Expand Up @@ -10,6 +10,8 @@
import org.springframework.web.bind.annotation.RestController;
import wanted.media.user.dto.SignUpRequest;
import wanted.media.user.dto.SignUpResponse;
import wanted.media.user.dto.UserLoginRequestDto;
import wanted.media.user.dto.UserLoginResponseDto;
import wanted.media.user.service.UserService;

@RestController
Expand All @@ -18,6 +20,12 @@
public class UserController {
private final UserService userService;

@PostMapping("/login")
public ResponseEntity<UserLoginResponseDto> loginUser(@RequestBody UserLoginRequestDto requestDto) {
UserLoginResponseDto responseDto = userService.login(requestDto);
return ResponseEntity.ok().body(responseDto);
}

//회원가입
@PostMapping("/sign-up")
public ResponseEntity<SignUpResponse> signUp(@Validated @RequestBody SignUpRequest request) {
Expand Down
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 @@ -22,4 +22,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;
}
}
60 changes: 60 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,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 {
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;
}
}
4 changes: 4 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,4 @@
package wanted.media.user.dto;

public record TokenRequestDto(String accessToken, 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;
}
Loading

0 comments on commit c78da78

Please sign in to comment.