package com.example.jwtvelog.config;
import com.example.jwtvelog.auth.jwt.JwtAuthorizationFilter;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// DB 드라이버 클래스 이름 (h2 사용 시 security 충돌 해결 위해)
@Value("${spring.datasource.driver-class-name}")
private String springDatasourceDriverClassName;
@Bean
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcher.Builder(introspector);
}
// custom Security Filter Manager 적용
// 추후 작성 예정
public static class CustomSecurityFilterManager
extends AbstractHttpConfigurer<CustomSecurityFilterManager, HttpSecurity> {
private final JwtAuthorizationFilter jwtAuthorizationFilter;
public CustomSecurityFilterManager(JwtAuthorizationFilter jwtAuthorizationFilter) {
this.jwtAuthorizationFilter = jwtAuthorizationFilter;
}
// jwt 필터를 UsernamePasswordAuthenticationFilter 전에 등록
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, MvcRequestMatcher.Builder mvc,
JwtAuthorizationFilter jwtAuthorizationFilter)
throws Exception {
// api 서버로 사용하기 때문에 csrf 해제 (jwt로 대체)
httpSecurity.csrf(config -> config.disable());
// 로그인 인증창이 뜨지 않게 비활성화
httpSecurity.httpBasic(config -> config.disable());
// form 로그인 해제
httpSecurity.formLogin(config -> config.disable());
// jSessionId 사용 거부
httpSecurity.sessionManagement(config -> config
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 커스텀 필터 적용 (시큐리티 필터 교환)
// 추후 작성 예정
httpSecurity.apply(new CustomSecurityFilterManager(jwtAuthorizationFilter));
// 인증, 권한 필터 설정
httpSecurity.authorizeHttpRequests(config -> config
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers(
mvc.pattern("/"),
mvc.pattern("/auth/**")
).permitAll()
.requestMatchers(mvc.pattern("/api/v1/auth/**")).permitAll()
.anyRequest().authenticated());
// DB 드라이버 클래스 이름이 h2일 경우, h2 관련 옵션 추가
if (springDatasourceDriverClassName.equals("org.h2.Driver")) {
// h2 관련 옵션
httpSecurity.headers(config -> config.frameOptions(frameOptionsConfig -> frameOptionsConfig.sameOrigin()));
}
return httpSecurity.getOrBuild();
}
}
package com.example.jwtvelog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
// 비밀번호 암호화를 위한 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
// 암호화 방식을 BCrypt로 지정
return new BCryptPasswordEncoder();
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a href="/auth/login">로그인</a>
<a href="/auth/sign-up">회원가입</a>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>sign-up</title>
</head>
<body>
<input type="text" name="email" id="email" placeholder="email">
<input type="password" name="password" id="password" placeholder="password">
<button id="sign-up">회원가입</button>
</body>
</html>
package com.example.jwtvelog.domain.auth.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/auth")
public class AuthController {
// 회원가입 페이지
@GetMapping("/sign-up")
public String signUp() {
return "sign-up";
}
}
package com.example.jwtvelog.domain.auth.controller;
import com.example.jwtvelog.common.exception.BadRequestException;
import com.example.jwtvelog.domain.auth.dto.ReqLoginApiV1DTO;
import com.example.jwtvelog.domain.auth.dto.ReqReLoginApiV1DTO;
import com.example.jwtvelog.domain.auth.dto.ReqSignUpApiV1DTO;
import com.example.jwtvelog.domain.auth.service.AuthServiceApiV1;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthControllerApiV1 {
private final AuthServiceApiV1 authServiceApiV1;
// 회원가입
@PostMapping("/sign-up")
public HttpEntity<?> signUp(@RequestBody @Valid ReqSignUpApiV1DTO reqSignUpApiV1DTO, Errors error) {
// Validation 중 에러 발생 시, BadRequestException 발생
if (error.hasErrors()) {
throw new BadRequestException(error.getAllErrors().get(0).getDefaultMessage());
}
return authServiceApiV1.signUp(reqSignUpApiV1DTO);
}
}
package com.example.jwtvelog.domain.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqSignUpApiV1DTO {
@NotBlank(message = "이메일이 정확하지 않습니다.")
@Email
private String email;
// 패스워드 규칙(영어 대/소문자, 숫자, 특수문자 모두 포함) 정규 표현식 설정
@NotBlank(message = "패스워드가 정확하지 않습니다.")
@Pattern(regexp ="^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*_,.?~]).{8,15}$")
private String password;
}
package com.example.jwtvelog.domain.auth.service;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.jwtvelog.auth.jwt.JwtProvider;
import com.example.jwtvelog.auth.jwt.JwtToken;
import com.example.jwtvelog.auth.jwt.JwtTokenType;
import com.example.jwtvelog.common.dto.ResDTO;
import com.example.jwtvelog.common.exception.UnauthorizedException;
import com.example.jwtvelog.domain.auth.dto.ReqLoginApiV1DTO;
import com.example.jwtvelog.domain.auth.dto.ReqReLoginApiV1DTO;
import com.example.jwtvelog.domain.auth.dto.ReqSignUpApiV1DTO;
import com.example.jwtvelog.model.member.entity.MemberEntity;
import com.example.jwtvelog.model.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceApiV1 {
private final MemberRepository memberRepository;
// 추후 구현, 사용 예정
private final JwtProvider jwtProvider;
private final PasswordEncoder passwordEncoder;
@Transactional
public HttpEntity<?> signUp(ReqSignUpApiV1DTO reqSignUpApiV1DTO) {
// ReqDTO 기반으로 유저 정보 생성
MemberEntity memberEntity = MemberEntity.builder()
.email(reqSignUpApiV1DTO.getEmail())
.password(passwordEncoder.encode(reqSignUpApiV1DTO.getPassword()))
.role("ROLE_MEMBER")
.build();
// 유저 정보 저장
memberRepository.save(memberEntity);
// 회원가입 성공
return new ResponseEntity<>(
ResDTO.builder()
.code(0)
.message("회원가입 성공")
.build(),
HttpStatus.OK);
}
}
package com.example.jwtvelog.model.member.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(of = "idx", callSuper = false)
@Entity
@Table(name = "`MEMBER`")
@DynamicInsert
@DynamicUpdate
public class MemberEntity {
// Idx
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "idx", updatable = false)
private Long idx;
// 이메일
@Column(name = "email", nullable = false)
private String email;
// 패스워드
// 인코딩된 문자열
@Column(name = "password", nullable = false)
private String password;
// role
@Column(name = "role", nullable = false)
private String role;
}
package com.example.jwtvelog.model.member.repository;
import com.example.jwtvelog.model.member.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
// 이메일로 유저 정보를 찾아온다.
Optional<MemberEntity> findByEmail(String email);
}
<script>
document.querySelector("#sign-up").addEventListener("click", () => {
// email, password를 가져온다.
const email = document.querySelector("#email").value;
const password = document.querySelector("#password").value;
// email, password를 JSON으로 만든다.
const reqDTO = {
email: email,
password: password
};
// ApiController에서 작성한 회원가입 API를 호출한다.
fetch("/api/v1/auth/sign-up", {
// 요청 메소드
method: "POST",
// 헤더 정보
headers: {
"Content-Type": "application/json"
},
// 요청 바디
body: JSON.stringify(reqDTO)
}) // 응답을 JSON으로 파싱한다.
.then(response => response.json())
// 파싱된 데이터 확인
.then((result) => {
// 응답 코드가 0이 아니면 에러 메시지를 출력한다.
if (result.code !== 0) {
alert(result.message);
return;
}
// 응답 코드가 0이면 메시지를 출력하고 메인 페이지로 이동한다.
alert(result.message);
window.location.href = "/";
}
)
});
</script>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<input type="text" name="email" id="email" placeholder="email">
<input type="password" name="password" id="password" placeholder="password">
<button id="login">로그인</button>
</body>
</html>
// 로그인 페이지
@GetMapping("/login")
public String login() {
return "login";
}
// 로그인
@PostMapping("/login")
public HttpEntity<?> login(@RequestBody @Valid ReqLoginApiV1DTO reqLoginApiV1DTO, Errors error) {
if (error.hasErrors()) {
throw new BadRequestException(error.getAllErrors().get(0).getDefaultMessage());
}
return authServiceApiV1.login(reqLoginApiV1DTO);
}
package com.example.jwtvelog.domain.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqLoginApiV1DTO {
@NotBlank(message = "이메일이 정확하지 않습니다.")
@Email
private String email;
@NotBlank(message = "패스워드가 정확하지 않습니다.")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*_,.?~]).{8,15}$")
private String password;
}
@Transactional
public HttpEntity<?> login(ReqLoginApiV1DTO reqLoginApiV1DTO) {
// 존재 여부 검사를 필수로 하기 위해 Optional로 감싸준다.
// 이메일로 DB에서 유저를 찾은 후
Optional<MemberEntity> memberEntityOptional = memberRepository.findByEmail(reqLoginApiV1DTO.getEmail());
// 유저가 존재하지 않으면 BadRequestException 발생
if (memberEntityOptional.isEmpty()) {
throw new BadRequestException("존재하지 않는 유저입니다.");
}
// 유저가 존재하면 memberEntity 추출 후
MemberEntity memberEntity = memberEntityOptional.get();
// passwordEncoder를 사용하여 패스워드가 일치하지 않는지 검사
if (!passwordEncoder.matches(reqLoginApiV1DTO.getPassword(), memberEntity.getPassword())) {
// 패스워드가 일치하지 않으면 BadRequestException 발생
throw new BadRequestException("패스워드가 일치하지 않습니다.");
}
// 패스워드가 일치하면 jwtProvider를 사용하여 accessToken, refreshToken 생성
String accessToken = jwtProvider.createToken(memberEntity, JwtTokenType.ACCESS_TOKEN);
String refreshToken = jwtProvider.createToken(memberEntity, JwtTokenType.REFRESH_TOKEN);
// accessToken, refreshToken 을 JwtToken 객체에 담아서 반환
return new ResponseEntity<>(
ResDTO.builder()
.code(0)
.message("로그인에 성공하였습니다.")
.data(JwtToken.builder().accessToken(accessToken)
.refreshToken(refreshToken).build())
.build(),
HttpStatus.OK);
}
package com.example.jwtvelog.auth.jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
@AllArgsConstructor
public class JwtToken {
private String accessToken;
private String refreshToken;
}
package com.example.jwtvelog.auth.jwt;
public enum JwtTokenType {
ACCESS_TOKEN, REFRESH_TOKEN
}
package com.example.jwtvelog.auth.jwt;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.jwtvelog.common.exception.UnauthorizedException;
import com.example.jwtvelog.model.member.entity.MemberEntity;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtProvider {
// 엑세스 토큰 유효기간 1일 설정
private static final int EXP_ACCESS = 1000 * 60 * 60 * 24;
// 리프레시 토큰 유효기간 7일 설정
private static final int EXP_REFRESH = 1000 * 60 * 60 * 24 * 7;
// 토큰 prefix 설정
public static final String TOKEN_PREFIX = "Bearer ";
// 토큰이 담길 헤더
public static final String HEADER = "Authorization";
// 토큰 암호화를 위한 시크릿 값 (테스트, 실제로는 이 값은 노출되면 안됨)
private String SECRET = "24fb2557fad0be76049e6677c3d7fcdb5ebe3cc4483f86751cfd7d4478a6ce6e";
// login 시 MemberEntity를 입력받아 AccessToken 생성
public String createToken(MemberEntity member, JwtTokenType tokenType) {
// 입력된 토큰 타입에 따라 유효기간 설정
int exp = tokenType.compareTo(JwtTokenType.ACCESS_TOKEN) == 0 ? EXP_ACCESS : EXP_REFRESH;
// 토큰 생성 후 반환
return JWT.create()
.withSubject(member.getIdx().toString()) // 고유값 (주제)
.withExpiresAt(new Date(System.currentTimeMillis() + exp)) // 만료 시간 설정 (현재 시간 + 유효기간)
// name을 따로 빼는 게 좋긴 함
.withClaim("role", member.getRole()) // 역할 claim 설정
.withClaim("token-type", tokenType.name()) // token-type claim 설정
.sign(Algorithm.HMAC512(SECRET)); // 시크릿 키를 이용한 암호화(서명)
}
// 토큰 검증 함수 (지금은 사용하지 않음, 추후 Filter 작성 시 사용)
// 토큰이 유효하면 DecodedJWT 객체를 반환하고, 유효하지 않으면 UnauthorizedException 발생
public DecodedJWT verify(String jwt) throws UnauthorizedException {
try {
// 시크릿 키를 이용해 토큰을 검증한다.
return JWT.require(Algorithm.HMAC512(SECRET))
.build().verify(jwt);
} catch (Exception e) {
// 검증 실패 시 예외 발생
throw new UnauthorizedException("token 값이 잘못되었습니다. " + e.getMessage());
}
}
}
AlgorithmMismatchException: 토큰 헤더에 명시된 알고리즘이 JWTVerifier에서 정의한 알고리즘과 다를 경우 발생합니다.
SignatureVerificationException: 서명이 유효하지 않을 경우 발생합니다.
TokenExpiredException: 토큰이 만료된 경우 발생합니다.
MissingClaimException: 검증해야 할 클레임이 누락되었을 경우 발생합니다.
IncorrectClaimException: 클레임이 예상과 다른 값을 가지고 있을 경우 발생합니다.
<script>
document.querySelector("#login").addEventListener("click", () => {
const email = document.querySelector("#email").value;
const password = document.querySelector("#password").value;
const reqDTO = {
email: email,
password: password
};
fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(reqDTO)
}).then(response => response.json())
.then((result) => {
if (result.code !== 0) {
alert(result.message);
window.location.href = "/";
return;
}
// result.code가 0일 시 로그인 성공
// 쿠키에 토큰 저장 (path 지정 하지 않을 시 '/'경로, 즉 localhost:8080/ 에서는 쿠키가 보이지 않음)
document.cookie = `ACCESS-TOKEN=${result.data.accessToken}; path=/`;
document.cookie = `REFRESH-TOKEN=${result.data.refreshToken}; path=/`;
alert(result.message);
window.location.href = "/";
}
)
});
</script>
을 구현할 것이다.