저번 포스팅에 이어서 이번에는 직접 코드를 작성하도록하겠습니다.
버전 : Spring Boot 2.7.2
SQL : JPA
IDEA : IntelliJ
디렉토리 구조
저희가 Spring Security와 JWT를 사용해야하므로 해당 Gradle을 추가해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
SpringSecurity 설정을 해주겠습니다. Spring Security를 사용하기위한 환경설정 Class라 생각하시면 될 것 같습니다.
스프링 버전에따라 Config작성법이 다른데 2.7 이전 버전에서는 WebSecurityConfigurerAdapter를 상속받지 않는다.(지원X)
public class SecurityConfig extends WebSecurityConfigurerAdapter
참고 자료
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
위 코드와 같이 WebSecurityComfigurerAdapter를 상속받아 configure메소드를 오버라이딩하여 작성했지만,
현재 실습하는 프로젝트의 Spring Boot 버전은 2.7.2 이므로 지원을 하지않는다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.httpBasic().disable()
.authorizeRequests()
.antMatchers("/test").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
따라서 이런 식으로 SecurityFilterChain을 Bean에 등록하여 환경설정을 해준다.
package com.Security;
import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.httpBasic().disable()
.authorizeRequests()
.antMatchers("/test").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers("/h2-console/**")
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
AuthenticationManager를 Bean에 등록해준다.
PasswordEncoder의 경우는 회원가입시 비밀번호를 암호화하기위해 Bean에 등록해주었습니다.
그리고 기존에 configure의 내용을 FilterChain메소드에 적어준다.
우리는 Header에 토큰을 주입하기 때문에 httpBasic().disable()해준다.
authorizeRequests() : 요청에 대한 사용권한 체크
antMatchers("/경로").authenticated() : 해당 경로는 사용권한을 체크한다.
antMatchers("/경로/**").hasRole("Role") : 해당 Role 권한이있는 사용자만 허용한다.
antMatchers("/경로").permitAll() : 해당 경로로는 권한체크안한다.
※ 본 예제에서는 test, admin, user를 제외한 경로는 권한체크를 안하게 했습니다.
addFilterBefore(커스텀 필터, 기존 필터) : 사용자가 커스텀한 필터를 기존필터 앞에다가 적용시켜준다. 그러면 기존필터 수행전에 커스텀한 필터가 수행된다.
JwtAuthenticationFilter를 커스텀하여 작성한다.
package com.Security;
import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@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);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
해당 필터에서 JWT를 이용하는 필터를 만들것이다. 해당 필터를 수행할때 수행할 내용을 doFilter메서드 안에 작성해준다.
request에있는 Token을 가지고 온 뒤, 토큰의 유효성을 검사하는 필터를 작성하였다. 마지막에서는 chain.doFilter(request, response); 를 작성하여 필터를 이어준다.
package com.Jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
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 javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "Darren";
//유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
//secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
//jwt 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk);// JWT PayLoad에 저장되는 정보단위, PK값
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(now) //토큰 발행시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) //Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) //암호화 알고리즘
.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();
}
//Header에서 token값을 가지고온다.
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
//토큰의 유효성 검사
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;
}
}
}
토큰과 관련된 클래스입니다. 이 클래스에서는 토큰을 생성, 조회, 검사 하는 메서드를 작성했습니다.
본 예제에서는 Token을 만들때 payload부분에 저희 User테이블의 PK값인 ID를 넣어주었습니다.
package com.Model;
import com.Role.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Users implements UserDetails {
@Id
private String id;
@Column(length = 255)
private String password;
@Column(length = 255, nullable = false)
private String email;
private String gender;
@Column(length = 13)
private String phone;
@Column(length = 8)
@Enumerated(EnumType.STRING)
private Role role;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails를 implemsnts하게 되면 다음 과 같은 메서드를 구현해야합니다.
저희가 [이론]때 스프링 시큐리티 동작방식에 대해 설명드렸는데요 그때 보시면 UserDetails타입으로 반환해야한다고 했는데 그때 사용하는 메서드들입니다.
- getUsername() : 사용자의 이름 (본 예제에서는 email을 출력)
- isAccountNonExpired() : 계정 만료 여부 (true 만료 안됨)
- isAccountNonLocked() : 계정 잠김 여부 (true 잠기지 않음)
- isCredentialsNonExpired() : 비밀번호 만료 여부 (true 만료 안됨)
- isEnabled() : 계정 활성화 여부 (true 활성화)
- getAuthorities() : 계정의 권한 목록
package com.Service;
import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class UserService implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
return usersRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
UserDetailsService를 implemnets를 하게되면 loadUserByUsername 메서드를 구현해야합니다. 이부분이 저번 포스팅때 UserDetailsService에서 해당 id가 DB에 존재하는지 확인 후 UserDetails로 리턴하는 메서드입니다.
package com.Controller;
import com.Jwt.JwtTokenProvider;
import com.Model.Users;
import com.Repository.UsersRepository;
import com.Role.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
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.RestController;
import java.util.Collections;
import java.util.Map;
@RequiredArgsConstructor
@RestController
public class UsersController {
@Autowired
private PasswordEncoder passwordEncoder;
private final UsersRepository usersRepository;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/test")
public String test(){
return "<h1>test 통과</h1>";
}
@PostMapping("/join")
public String signUp(@RequestBody Map<String, String> user) {
Role role = Role.from(user.get("role"));
return usersRepository.save(Users.builder()
.id(user.get("id"))
.password(passwordEncoder.encode(user.get("password")))
.email(user.get("email"))
.gender(user.get("gender"))
.phone(user.get("phone"))
.role(role)
.roles(Collections.singletonList(role.getValue()))
.build()).toString();
}
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
Users userinfo = usersRepository.findByEmail(user.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 아이디입니다."));
if(!passwordEncoder.matches(user.get("password"), userinfo.getPassword())) {
throw new IllegalArgumentException("잘못된 비밀번호 입니다.");
}
return jwtTokenProvider.createToken(userinfo.getId(), userinfo.getRoles());
}
}
아까 SecurityConfig 클래스에서 PasswordEncoder를 Bean에 주입했으므로 @Autowired 를 붙여준다.
/join으로 요청시 Role은 EnumType이므로 user.get("role")은 반환값이 String이므로 from메소드를 이용하여 Role 타입으로 리턴하게끔 해주었습니다.
package com.Role;
import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum Role {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
private String value;
@JsonCreator
public static Role from(String s) {
return Role.valueOf(s.toUpperCase());
}
}
다음과 같이 작성해주었습니다.
Repository
package com.Repository;
import com.Model.Users;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UsersRepository extends JpaRepository<Users, String> {
Optional<Users> findById(String id);
Optional<Users> findByEmail(String email);
Page<Users> findAll(Pageable pageable);
}
Application
package com;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@SpringBootApplication
public class JwtApplication {
public static void main(String[] args) {
SpringApplication.run(JwtApplication.class, args);
}
}
application.properties
spring.devtools.livereload.enabled=true
spring.freemarker.cache=false
# DATABASE
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:~/local
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=jwt
spring.datasource.password=
# JPA
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.show_sql=true
Test
JwtApplication을 실행하여 서버를 활성화시켜준 뒤 POSTMAN을 이용하여 테스트해보겠습니다.
다음과 같이 API요청을 받으면 회원가입이 되고 암호화도 잘 되고, Role도 정상적으로 Insert된 것을 확인할 수 있습니다.
로그인 후 JWT을 리턴받았습니다. 이 토큰을 가지고 로그인을 진행할건데, 프론트측에서 Header에 넣어주지만 저희는 프론트없이 진행하기 때문에 포스트맨을 통해 직접 넣어주겠습니다.
이런식으로 Header에 Authorization에 방금 발급받은 JWT를 넣어주면 정상적으로 작동합니다.
참고 자료
'스프링' 카테고리의 다른 글
[KaKaoLogin RestAPI] oAuth2.0 + SpringBoot - (1) (0) | 2022.08.19 |
---|---|
[Spring Security] + [JWT] + [RefreshToken] 스프링 시큐리티 JWT 로그인 적용기 (0) | 2022.08.18 |
[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[이론] (1) | 2022.08.12 |
스프링부트 게시판 작성/목록 머스테치로 구현하기 (0) | 2022.01.26 |
스프링 부트 JPA 게시물 수정 / 변경 (0) | 2022.01.17 |