Spring Boot 2.7.5
Java 17
MySQL 8.0
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
testImplementation 'org.springframework.security:spring-security-test'
}
com.{패키지명}.domain.Member.java
import java.util.UUID;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
public class Member extends BaseTimeEntity {
@Id
@Column(name = "member_id", columnDefinition = "BINARY(16)")
@GeneratedValue
private UUID id;
@Column(length = 100, unique = true, nullable = false)
private String email;
private String password;
@Column(unique = true, nullable = false)
private String nickname;
private String phone;
@Enumerated(EnumType.STRING)
private Role role;
}
Role은 관리하기 좀 더 편하도록 Enum으로 만들어주었다.
com.{패키지명}.domain.Role.java
public enum Role {
ROLE_MEMBER, ROLE_ADMIN
}
com.{패키지명}.repository.MemberRepository.java;
import java.util.Optional;
import com.realtimechat.client.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, String> {
Optional<Member> findByEmail(String email);
}
com.{패키지명}.config.security.SecurityConfig.java;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
// 비밀번호 암호화
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager를 Bean 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 만을 고려하여 기본설정 해제
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 사용 안함
.and()
.authorizeRequests() // 요청에 대한 사용 권한 체크
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/api/post/**").authenticated()
.anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음
return http.build();
}
}
책 또는 다른 사이트에서 SecurityConfig extends WebSecurityConfigurerAdapter를 사용하는 경우가 많은데 Spring Boot 2.7+버전에서부터는 사용을 권장하지 않으므로 SecurityFilterChain Bean 등록을 통해 작성하였습니다.
com.{패키지명}.config.security.JwtTokenProvider.java;
import java.util.Base64;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "test";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPK, Role roles) {
Claims claims = Jwts.claims().setSubject(userPK); // JWT payload에 저장되는 정보 단위
claims.put("roles", roles); // 정보 저장 (key-value)
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPK(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPK(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN": "TOKEN 값"
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
com.{패키지명}.config.security.JwtAuthenticationFilter.java;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT를 받아옴
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옴
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
-> 인증 관리자는 UserDetailsService 객체를 통해 UserDetails 객체를 획득, UserDetails 객체에서 인증(Authentication)과 인가(Authorization)에 필요한 정보들을 추출하여 사용
Member.java에 implements UserDetails를 하여 사용 해도 되지만 Override 해야 할 추상 메소드들이 생기므로 스프링이 제공하는 User 클래스를 상속하여 새로운 클래스를 정의하였습니다.
com.{패키지명}.config.security.SecurityUser.java;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
public class SecurityUser extends User {
private Member member;
public SecurityUser(Member member) {
super(member.getId().toString(), member.getPassword(),
AuthorityUtils.createAuthorityList(member.getRole().toString()));
this.member = member;
}
public Member getMember() {
return member;
}
}
com.{패키지명}.config.security.UserDetailsService.java;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class SecurityUserDetailService implements UserDetailsService {
@Autowired
private MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optional = memberRepository.findByEmail(username);
if(!optional.isPresent()) {
throw new UsernameNotFoundException(username + " 사용자 없음");
} else {
Member member = optional.get();
return new SecurityUser(member);
}
}
}
com.{패키지명}.controller.MemberController.java;
import java.util.Map;
import java.util.UUID;
import org.springframework.security.crypto.password.PasswordEncoder;
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 lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class MemberController {
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
// 회원가입
@PostMapping("/register")
public UUID register(@RequestBody Map<String, String> user) {
return memberRepository.save(Member.builder()
.email(user.get("email"))
.password(passwordEncoder.encode(user.get("password")))
.nickname(user.get("nickname"))
.phone(user.get("phone"))
.role(Role.ROLE_MEMBER)
.build()).getId();
}
// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
Member member = memberRepository.findByEmail(user.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입 되지 않은 이메일입니다."));
if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
throw new IllegalArgumentException("이메일 또는 비밀번호가 맞지 않습니다.");
}
return jwtTokenProvider.createToken(member.getEmail(), member.getRole());
}
}
서버 실행 후 postman 으로 로그인 테스트 결과
토큰이 발급되는 것을 확인할 수 있습니다.
참고 사이트
https://webfirewood.tistory.com/115
참고 도서
https://product.kyobobook.co.kr/detail/S000001891089