Skip to content

Commit 1280127

Browse files
Merge pull request #148 from prgrms-aibe-devcourse/release/#145-v01-develop
Release/#145 v01 develop
2 parents bb7516d + c61ac55 commit 1280127

183 files changed

Lines changed: 17361 additions & 30 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 0 additions & 18 deletions
This file was deleted.

build.gradle

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies {
2626
implementation 'org.springframework.boot:spring-boot-starter-websocket'
2727
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'
2828
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
29+
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
2930
compileOnly 'org.projectlombok:lombok'
3031
developmentOnly 'org.springframework.boot:spring-boot-devtools'
3132
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
@@ -37,6 +38,37 @@ dependencies {
3738
testCompileOnly 'org.projectlombok:lombok'
3839
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
3940
testAnnotationProcessor 'org.projectlombok:lombok'
41+
42+
// QueryDSL
43+
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
44+
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
45+
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
46+
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
47+
48+
// 인증 메일
49+
implementation 'org.springframework.boot:spring-boot-starter-mail'
50+
51+
// JWT
52+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
53+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
54+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
55+
}
56+
57+
// Q클래스 경로설정
58+
def generated = 'src/main/generated'
59+
60+
// 경로에 저장
61+
tasks.withType(JavaCompile) {
62+
options.getGeneratedSourceOutputDirectory().set(file(generated))
63+
}
64+
65+
// 소스 폴더 인식
66+
sourceSets {
67+
main.java.srcDirs += [generated]
68+
}
69+
70+
clean {
71+
delete file(generated)
4072
}
4173

4274
tasks.named('test') {

gradlew

100644100755
File mode changed.

src/main/java/com/knoc/KnocApplication.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
57

8+
@EnableJpaAuditing
9+
@EnableScheduling
610
@SpringBootApplication
711
public class KnocApplication {
812

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.knoc.auth.controller;
2+
3+
import com.knoc.auth.dto.SignUpDto;
4+
import com.knoc.global.exception.BusinessException;
5+
import com.knoc.member.service.MemberService;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.stereotype.Controller;
11+
import org.springframework.ui.Model;
12+
import org.springframework.validation.BindingResult;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.ModelAttribute;
15+
import org.springframework.web.bind.annotation.PostMapping;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
18+
19+
@Tag(name="Auth-Controller",description = "회원 인증 관련 API")
20+
@Controller
21+
@RequestMapping("/auth")
22+
@RequiredArgsConstructor
23+
public class AuthController {
24+
25+
private final MemberService memberService;
26+
27+
@Operation(summary = "로그인 페이지 조회", description = "로그인 폼 화면을 반환합니다.")
28+
@GetMapping("/login")
29+
public String loginForm() {
30+
return "auth/login";
31+
}
32+
33+
@Operation(summary = "회원가입 페이지 조회", description = "회원가입 폼 화면을 반환합니다.")
34+
@GetMapping("/signup")
35+
public String signUpForm(Model model) {
36+
model.addAttribute("signupDto", new SignUpDto());
37+
return "auth/signup";
38+
}
39+
40+
@Operation(summary = "회원가입 처리", description = "전달받은 정보를 바탕으로 회원가입을 처리합니다.")
41+
@PostMapping("/signup")
42+
public String processSignUp(@Valid @ModelAttribute("signupDto") SignUpDto dto, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) {
43+
44+
if (dto.getPassword() != null && !dto.getPassword().equals(dto.getPasswordConfirm())) {
45+
bindingResult.rejectValue("passwordConfirm", "passwordInCorrect", "비밀번호가 일치하지 않습니다.");
46+
}
47+
// 폼 형식 검증 에러 처리 (비밀번호 자리수, 이메일 형식 등)
48+
if(bindingResult.hasErrors()) {
49+
return "auth/signup"; // 에러가 있으면 다시 회원가입 페이지
50+
}
51+
// 이메일, 닉네임 중복 검사
52+
try {
53+
memberService.registerMember(dto);
54+
redirectAttributes.addFlashAttribute("successMessage", "회원가입이 완료되었습니다!");
55+
return "redirect:/auth/login"; // 성공 시 로그인 페이지로 이동
56+
} catch(BusinessException e) {
57+
model.addAttribute("errorMessage", e.getMessage());
58+
model.addAttribute("signupDto", dto);
59+
return "auth/signup"; // 에러 발생 시 에러 메시지 출력 후 화원가입 페이지 유지
60+
}
61+
}
62+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.knoc.auth.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
6+
import jakarta.validation.constraints.Size;
7+
import lombok.Getter;
8+
import lombok.Setter;
9+
10+
@Getter @Setter
11+
public class SignUpDto {
12+
@NotBlank(message = "닉네임은 필수 입력값입니다.")
13+
@Size(min = 2, max = 10, message = "닉네임은 2~10자리여야 합니다.")
14+
private String nickname;
15+
16+
@NotBlank(message = "이메일은 필수 입력값입니다.")
17+
@Email(message = "올바른 이메일 형식이 아닙니다.")
18+
private String email;
19+
20+
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
21+
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문, 숫자, 특수문자를 사용하세요.")
22+
private String password;
23+
24+
@NotBlank(message = "비밀번호 확인은 필수 입력값입니다.")
25+
private String passwordConfirm;
26+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.knoc.auth.jwt;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.Cookie;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
import org.springframework.security.core.userdetails.UserDetailsService;
13+
import org.springframework.web.filter.OncePerRequestFilter;
14+
15+
import java.io.IOException;
16+
import java.util.Arrays;
17+
18+
@RequiredArgsConstructor
19+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
20+
21+
private final JwtTokenProvider jwtTokenProvider;
22+
private final UserDetailsService userDetailsService;
23+
24+
@Override
25+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
26+
throws ServletException, IOException {
27+
28+
String accessToken = getCookieValue(request, "accessToken");
29+
String refreshToken = getCookieValue(request, "refreshToken");
30+
31+
// access token 살아있으면 통과
32+
if(accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
33+
setAuthentication(jwtTokenProvider.getEmailFromToken(accessToken));
34+
}
35+
// access token 만료, refresh token이 유효하면 access token 재발급
36+
else if(refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
37+
String email = jwtTokenProvider.getEmailFromToken(refreshToken);
38+
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
39+
String role = userDetails.getAuthorities().iterator().next().getAuthority();
40+
41+
String newAccessToken = jwtTokenProvider.createAccessToken(email, role);
42+
Cookie newCookie = new Cookie("accessToken", newAccessToken);
43+
newCookie.setPath("/");
44+
newCookie.setHttpOnly(true);
45+
newCookie.setMaxAge(60 * 30);
46+
response.addCookie(newCookie);
47+
48+
setAuthentication(email);
49+
50+
}
51+
filterChain.doFilter(request, response);
52+
53+
}
54+
@Override
55+
protected boolean shouldNotFilter(HttpServletRequest request) {
56+
String path = request.getRequestURI();
57+
// /ws 로 시작하는 웹소켓 연결 요청은 HTTP 단계에서 토큰 검사를 하지 않도록 예외 처리
58+
return path.startsWith("/ws");
59+
}
60+
61+
// 쿠키 배열에서 원하는 쿠키를 가져옴
62+
private String getCookieValue(HttpServletRequest request, String cookieName) {
63+
if(request.getCookies() == null) return null;
64+
return Arrays.stream(request.getCookies())
65+
.filter(c -> c.getName().equals(cookieName))
66+
.map(Cookie::getValue)
67+
.findFirst()
68+
.orElse(null);
69+
70+
}
71+
72+
private void setAuthentication(String email) {
73+
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
74+
UsernamePasswordAuthenticationToken authentication =
75+
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
76+
SecurityContextHolder.getContext().setAuthentication(authentication);
77+
}
78+
79+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.knoc.auth.jwt;
2+
3+
import jakarta.servlet.http.Cookie;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import lombok.RequiredArgsConstructor;
6+
import org.jspecify.annotations.Nullable;
7+
import org.springframework.http.server.ServerHttpRequest;
8+
import org.springframework.http.server.ServerHttpResponse;
9+
import org.springframework.http.server.ServletServerHttpRequest;
10+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
import org.springframework.security.core.userdetails.UserDetailsService;
13+
import org.springframework.stereotype.Component;
14+
import org.springframework.web.socket.WebSocketHandler;
15+
import org.springframework.web.socket.server.HandshakeInterceptor;
16+
17+
import java.util.Map;
18+
19+
@Component
20+
@RequiredArgsConstructor
21+
public class JwtHandshakeInterceptor implements HandshakeInterceptor {
22+
/** WebSocket 핸드셰이크 인터셉터
23+
* 연결 수립 전 쿠키에서 JWT를 추출해 검증하고,
24+
* 인증 정보를 attributes에 저장하여 Principal로 사용할 수 있게 함
25+
* 검증 실패 시 false 반환 → 연결 거부
26+
*/
27+
28+
private final JwtTokenProvider jwtTokenProvider;
29+
private final UserDetailsService userDetailsService;
30+
31+
@Override
32+
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
33+
// 쿠키 -> JWT검증 -> 인증정보 저장
34+
if (request instanceof ServletServerHttpRequest servletRequest){
35+
HttpServletRequest httpRequest = servletRequest.getServletRequest();
36+
String accessToken = null;
37+
Cookie[] cookies = httpRequest.getCookies();
38+
if (cookies != null) {
39+
for (Cookie cookie : cookies) {
40+
if ("accessToken".equals(cookie.getName())){
41+
accessToken = cookie.getValue();
42+
break;
43+
}
44+
}
45+
}
46+
47+
if(jwtTokenProvider.validateToken(accessToken)){
48+
String email = jwtTokenProvider.getEmailFromToken(accessToken);
49+
50+
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
51+
52+
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
53+
54+
attributes.put("principal", authentication);
55+
56+
return true;
57+
}
58+
}
59+
60+
return false;
61+
}
62+
63+
@Override
64+
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, @Nullable Exception exception) {
65+
// 핸드셰이크 완료 후 별도 처리 없음
66+
}
67+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.knoc.auth.jwt;
2+
3+
import io.jsonwebtoken.Claims;
4+
import io.jsonwebtoken.Jwts;
5+
import io.jsonwebtoken.SignatureAlgorithm;
6+
import io.jsonwebtoken.security.Keys;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.security.Key;
12+
import java.util.Date;
13+
14+
@Slf4j
15+
@Component
16+
public class JwtTokenProvider {
17+
private final Key key;
18+
private final long accessTokenValidityTime = 1000L * 60 * 30; // 30분
19+
private final long refreshTokenValidityTime = 1000L * 60 * 60 * 24 * 7; //7일
20+
21+
// Access Token 생성
22+
public JwtTokenProvider(@Value("${jwt.secret:vmsqi69s1iq93p01dnscms938s9d10s2j3nzmcl0s9}")String secretKey) {
23+
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
24+
}
25+
26+
public String createAccessToken(String email, String role) {
27+
Claims claims = Jwts.claims().setSubject(email);
28+
claims.put("role", role);
29+
Date now = new Date();
30+
31+
return Jwts.builder()
32+
.setClaims(claims)
33+
.setIssuedAt((now)) // 발급 시간
34+
.setExpiration(new Date(now.getTime() + accessTokenValidityTime)) // 만료 시간
35+
.signWith(key, SignatureAlgorithm.HS256)
36+
.compact();
37+
}
38+
39+
// refreshToken 생성(수명 7일, 이메일 저장)
40+
public String createRefreshToken(String email) {
41+
Date now = new Date();
42+
return Jwts.builder()
43+
.setSubject(email)
44+
.setIssuedAt(now)
45+
.setExpiration(new Date(now.getTime() + refreshTokenValidityTime))
46+
.signWith(key, SignatureAlgorithm.HS256)
47+
.compact();
48+
49+
}
50+
51+
public boolean validateToken(String token) {
52+
try {
53+
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
54+
return true;
55+
} catch (Exception e) {
56+
log.error("JWT 검증 실패: {}", e.getMessage());
57+
return false;
58+
}
59+
}
60+
61+
// 토큰에서 이메일 추출
62+
public String getEmailFromToken(String token) {
63+
return Jwts.parserBuilder().setSigningKey(key).build()
64+
.parseClaimsJws(token).getBody().getSubject();
65+
}
66+
67+
}

0 commit comments

Comments
 (0)