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

Feature/becooq81 step1 #5

Open
wants to merge 60 commits into
base: becooq81
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
4747c16
feat: auth controller
becooq81 Oct 10, 2023
7e65b88
feat: jwt code
becooq81 Oct 10, 2023
dfbcbc2
feat: token info
becooq81 Oct 10, 2023
2e4aae7
feat: login request
becooq81 Oct 10, 2023
63295b7
feat: jwt auth filter
becooq81 Oct 10, 2023
8d3bed6
feat: jwt token provider
becooq81 Oct 10, 2023
8578d10
feat: query dsl config
becooq81 Oct 10, 2023
9340f26
feat: security config
becooq81 Oct 10, 2023
50032c1
feat: swagger config
becooq81 Oct 10, 2023
91786bd
feat: error response
becooq81 Oct 10, 2023
0ac1b03
feat: global exception handler
becooq81 Oct 10, 2023
4e15829
feat: member controller
becooq81 Oct 10, 2023
c6d5666
feat: member domain
becooq81 Oct 10, 2023
b4d9902
feat: refresh token
becooq81 Oct 10, 2023
a57a047
feat: member response
becooq81 Oct 10, 2023
aa97e43
feat: signup request
becooq81 Oct 10, 2023
3fa0df6
feat: update member request
becooq81 Oct 10, 2023
8d2d636
feat: member repository
becooq81 Oct 10, 2023
d7e2aec
feat: refresh token repository
becooq81 Oct 10, 2023
840f8ff
feat: custom user details service
becooq81 Oct 10, 2023
b76e369
feat: member service interface
becooq81 Oct 10, 2023
2b3173a
feat: member service
becooq81 Oct 10, 2023
21cdbd7
feat: password matches validator
becooq81 Oct 10, 2023
e206db6
feat: validate email
becooq81 Oct 10, 2023
fc6fc9f
feat: constraints added
becooq81 Oct 10, 2023
9ddfaaf
fix: filter chain
becooq81 Oct 10, 2023
22cfdc7
fix: imports
becooq81 Oct 10, 2023
3709231
fix: delete redundant code
becooq81 Oct 10, 2023
fb904f0
fix
becooq81 Oct 10, 2023
76517ee
fix: user info
becooq81 Oct 11, 2023
4a154e8
fix
becooq81 Oct 11, 2023
9f8f91c
changes
becooq81 Oct 11, 2023
41d775c
feat: friendship
becooq81 Oct 10, 2023
e102f1e
refactor
becooq81 Oct 10, 2023
0650ab4
feat: friendship repository
becooq81 Oct 10, 2023
6a283db
fix: friendship repository
becooq81 Oct 10, 2023
eda53db
feat: friendship service
becooq81 Oct 10, 2023
f1a2c3b
feat: friendship service
becooq81 Oct 10, 2023
f8e1bf7
fix: entity name
becooq81 Oct 10, 2023
bcba278
feat: friendship request
becooq81 Oct 10, 2023
9ad2177
feat: friendship response
becooq81 Oct 10, 2023
0b96a47
feat(MemberService): find by username
becooq81 Oct 10, 2023
3bd27da
feat: friendship controller
becooq81 Oct 10, 2023
72e0314
fix: friendship request
becooq81 Oct 10, 2023
8e0d070
feat: converter
becooq81 Oct 10, 2023
7e112d1
feat: friendship service
becooq81 Oct 10, 2023
14a2723
fix: error
becooq81 Oct 10, 2023
804e0d5
fix: friendship
becooq81 Oct 10, 2023
3981db1
fix
becooq81 Oct 10, 2023
5bf6ec0
fix
becooq81 Oct 10, 2023
91c7447
fix
becooq81 Oct 11, 2023
c4a5e30
fix: git ignore
becooq81 Oct 11, 2023
114c83d
fixed untracked files
becooq81 Oct 10, 2023
f9047d2
fix
becooq81 Oct 10, 2023
6d218b2
fix: rebased due to errors in this branch
becooq81 Oct 11, 2023
53c4e50
feat: added email as index for query performance
becooq81 Oct 11, 2023
cdb5b0a
refactor: reorganized files
becooq81 Oct 11, 2023
340532d
fix: separated auth from member logic
becooq81 Oct 11, 2023
971b37f
fix: removed unused methods
becooq81 Oct 11, 2023
7bfbd56
fix: remove unused methods
becooq81 Oct 11, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.gdscys.cokepoke.auth.controller;

import com.gdscys.cokepoke.auth.domain.TokenInfo;
import com.gdscys.cokepoke.auth.dto.LoginRequest;
import com.gdscys.cokepoke.member.service.CustomUserDetailsService;
import com.gdscys.cokepoke.member.domain.Member;
import com.gdscys.cokepoke.member.dto.SignupRequest;
import com.gdscys.cokepoke.member.dto.MemberResponse;
import javax.validation.Valid;

import com.gdscys.cokepoke.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import static org.springframework.http.HttpHeaders.SET_COOKIE;

@Controller
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final CustomUserDetailsService customUserDetailsService;
private final MemberService memberService;

@PostMapping("/signup")
public ResponseEntity<MemberResponse> signup(@RequestBody @Valid SignupRequest request) {
Member member = memberService.saveMember(request.getEmail(), request.getUsername(), request.getPassword());
return ResponseEntity.status(HttpStatus.CREATED)
.body(MemberResponse.of(member));
}

@PostMapping(value = "/login")
public ResponseEntity<TokenInfo> login(@RequestBody @Valid LoginRequest loginRequest) {
TokenInfo tokenInfo = memberService.login(loginRequest.getEmail(), loginRequest.getPassword());
return ResponseEntity.ok()
.header(SET_COOKIE, generateCookie("accessToken", tokenInfo.getAccessToken()).toString())
.header(SET_COOKIE, generateCookie("refreshToken", tokenInfo.getRefreshToken()).toString())
.body(tokenInfo);
}

private ResponseCookie generateCookie(String from, String token) {
return ResponseCookie.from(from, token)
.httpOnly(true) // false로 하면 클라이언트도 쿠키로 접근할 수 있기 때문에 보안상 조치
.path("/")
.build();
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/gdscys/cokepoke/auth/domain/JwtCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.gdscys.cokepoke.auth.domain;

public enum JwtCode {
ACCESS,EXPIRED,DENIED
}
16 changes: 16 additions & 0 deletions src/main/java/com/gdscys/cokepoke/auth/domain/TokenInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.gdscys.cokepoke.auth.domain;

import lombok.Getter;

@Getter
public class TokenInfo {
private String accessToken;
private String refreshToken;

protected TokenInfo() {}

public TokenInfo(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/gdscys/cokepoke/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.gdscys.cokepoke.auth.dto;

import com.gdscys.cokepoke.validation.declaration.ValidEmail;
import lombok.Getter;

import javax.validation.constraints.NotBlank;

@Getter
public class LoginRequest {

@ValidEmail
private String email;

@NotBlank
private String password;

protected LoginRequest() {}

public LoginRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
105 changes: 105 additions & 0 deletions src/main/java/com/gdscys/cokepoke/auth/jwt/JwtAuthFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.gdscys.cokepoke.auth.jwt;


import com.gdscys.cokepoke.auth.domain.JwtCode;
import com.gdscys.cokepoke.auth.domain.TokenInfo;
import com.gdscys.cokepoke.member.domain.RefreshToken;
import com.gdscys.cokepoke.member.repository.RefreshTokenRepository;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Optional<String> accessToken = extractTokenFromCookie(request, "accessToken");
Optional<String> refreshToken = extractTokenFromCookie(request, "refreshToken");

// 유효한 토큰인지 확인합니다.
if (accessToken.isPresent() && jwtTokenProvider.validateToken(accessToken.get()) == JwtCode.ACCESS) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken.get());
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
} else if (accessToken.isPresent() && jwtTokenProvider.validateToken(accessToken.get()) == JwtCode.EXPIRED) {
log.info("Access token expired");

// refresh token 검증
if (refreshToken.isPresent() && jwtTokenProvider.validateToken(refreshToken.get()) == JwtCode.ACCESS) {

Optional<RefreshToken> savedToken = refreshTokenRepository.findByRefreshToken(refreshToken.get());

Claims claims = jwtTokenProvider.parseClaims(accessToken.get());

if (savedToken.isPresent() && claims.get("email").equals(savedToken.get().getMember().getEmail())) {
// accessToken 으로 부터 Authentication 객체 추출
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken.get());

// email 을 추출하여 accessToken, refreshToken 생성
TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication, savedToken.get().getMember().getEmail());

// 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);

// refreshToken 업데이트
savedToken.get().setRefreshToken(tokenInfo.getRefreshToken());
refreshTokenRepository.save(savedToken.get());

response.addCookie(jwtTokenProvider.generateCookie("refreshToken", tokenInfo.getRefreshToken()));
response.addCookie(jwtTokenProvider.generateCookie("accessToken", tokenInfo.getAccessToken()));

log.info("Reissue access token");
}
}
}

filterChain.doFilter(request, response);
}

private Optional<String> extractTokenFromCookie(HttpServletRequest request, String cookieName) {

if (request.getCookies() == null || request.getCookies().length == 0) return Optional.empty();

return Arrays.stream(request.getCookies())
.sequential()
.filter(cookie -> cookie.getName().equals(cookieName))
.map(Cookie::getValue)
.findFirst();
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// request 에서 요청 path 추출
String path = request.getServletPath();

// filter 에서 제외한 url 목록
String[] excludedPaths = { "/auth/login", "/auth/signup", "/h2-console"};

for (String excludedPath : excludedPaths) {
if (path.startsWith(excludedPath)) {
return true;
}
}

return false;
}
}
117 changes: 117 additions & 0 deletions src/main/java/com/gdscys/cokepoke/auth/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.gdscys.cokepoke.auth.jwt;

import com.gdscys.cokepoke.auth.domain.JwtCode;
import com.gdscys.cokepoke.auth.domain.TokenInfo;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.AccessDeniedException;
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 javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.Cookie;
import java.security.Key;
import java.sql.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;

public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Base64.getDecoder().decode(secretKey.getBytes());
this.key = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}

public TokenInfo generateToken(Authentication authentication, String email) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));

LocalDateTime now = LocalDateTime.now();

String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.claim("email", email)
.setExpiration(Date.from(now
.plusMinutes(30)
.atZone(ZoneId.systemDefault()).toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();

String refreshToken = Jwts.builder()
.setExpiration(Date.from(now
.plusDays(14)
.atZone(ZoneId.systemDefault()).toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();

return new TokenInfo(accessToken, refreshToken);
}

// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);

if (claims.get("auth") == null) {
throw new AccessDeniedException("권한 정보가 없는 토큰입니다.");
}

// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());

// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}


public JwtCode validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return JwtCode.ACCESS;
} catch (ExpiredJwtException e) {
// 기한 만료
return JwtCode.EXPIRED;
} catch (Exception e) {
// parsing 에러
return JwtCode.DENIED;
}
}

public Claims parseClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}

public Cookie generateCookie(String from, String token) {
Cookie cookie = new Cookie(from, token);

cookie.setPath("/");
cookie.setHttpOnly(true); // XSS 공격을 막기 위한 설정
cookie.setSecure(true);

return cookie;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.gdscys.cokepoke.configuration;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@EnableJpaAuditing
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Loading