build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'com.auth0:java-jwt:3.19.2'
}
application.yml
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create
show-sql: true
controller
@RequiredArgsConstructor
@RestController
public class RestApiController {
@GetMapping("/")
public String root() {
return "root";
}
}
Member.class
@Data
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long memberId;
private String username;
private String password;
private String role;
public List<String> roles() {
if(role.length() > 0) {
return Arrays.asList(role.split(","));
}
return new ArrayList<>();
}
}
SecurityConfig.class
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http
.formLogin().disable()
.httpBasic().disable();
http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/api/v1/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**")
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().authenticated();
return http.build();
}
JWT토큰 구현을 위한 기본적인 filterChain 설정
.csrf().disable();
: 현재 프로젝트는 stateless 이고 쿠키나 세션을 사용하지 않아 disable로 설정
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
: JWT는 Stateless상태를 유지해야 하므로 설정
.formLogin().disable()
: form login 사용x
.httpBasic().disable();
: http통신시 header에 Authorization값을 id, password를 입력하는 방식
-> 더 이상 브라우저에서 id, pwd를 헤더에 들고오지 않고 로그인시 발급된 토큰을 들고 오게 함.
HTTP Basic Authentication
username, password를 base64로 인코딩 하는 방법이다
보안에 매우 취약해서 HTTPS와 같이 사용되어야 한다.
CorsConfig.class
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
CorsFilter
: CORS pre-flight request를 다루기 위한 필터이다.
setAllowCredentials()
: 서버 응답시 JSON을 자바스크립트에서 처리 가능하도록 설정
addAllowedOrigin("*")
: 모든 ip에 응답 허용
addAllowedHeader("*")
: 모든 Header에 응답 허용
addAllowedMethod("*")
: 모든 HttpMethod에 응답 허용(Get, Post, Put, ... ), Cors pre-flight request 시 OPTIONS 메서드를 접근을 막는 경우 방지.
SecurityConfig.class
에 추가
@Autowired
private CorsFilter corsFilter;
...
public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder
.addFilter(corsFilter);
}
}
PrincipalDetails.java
@Data
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
private Member member;
public PrincipalDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
member.roles().forEach(n -> authorities.add(() ->n));
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
MemberRepository
public interface MemberRepository extends JpaRepository<Member,Long> {
Member findByUsername(String username);
}
PrincipalDetailsService.class
@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username);
return new PrincipalDetails(member);
}
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { //로그인 처리
//아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.
private final AuthenticationManager authenticationManager;
//인자로 받은 Authentication이 유효한 인증인지 확인하고, UserDetails를 통해 "Authentication" 객체를 리턴
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
ObjectMapper om = new ObjectMapper();
Member member = om.readValue(request.getInputStream(), Member.class);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getUsername(), member.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
//loadUserByName()이 실행된 후 정상 작동이 되면 authentication 리턴
return authentication;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//로그인 인증 성공 이후 과정
System.out.println("successfulAuthentication");
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String jwToken = JWT.create() //jwt토큰 생성
.withSubject("jwt token")//토큰 이름
.withExpiresAt(new Date(System.currentTimeMillis() + 60 * 1000 * 10)) //유효시간 10분
.withClaim("id", principalDetails.getMember().getMemberId()) //payLoad부분(토큰에 담길 정보)
.withClaim("username", principalDetails.getMember().getUsername()) //payLoad부분(토근에 담길 정보)
.sign(Algorithm.HMAC512("jwt"));//Signature 부분, 알고리즘 종류와 secretKey를 넣는다.
response.addHeader("Authorization", "Bearer " + jwToken); //header부분
}
}
회원의 로그인 처리를 담당하는 필터
attemptAuthentication() 메서드는 UsernamePasswordAuthenticationFilter의 메서드로 외부 인증시 UsernamePasswordAuthenticationFilter기능을 대신하는 커스텀 필터이다.
PrincipalDetailService의 loadUserByUsername() 메서드가 실행된 후 정상 작동된다면 authentication이 return된다.(AuthenticationManager의 authenticate()가 인증 처리 이후 Authentication객체로 저장, 리턴)
이후 successfulAuthentication() 메서드로 JWT 토큰을 생성한다.
JwtAuthorizationFilter
public class JwtAuthrizationFilter extends BasicAuthenticationFilter {
// 권한 및 인증이 필요한 주소를 요청하면 BasicAuthenticationFilter를 진행하게 되는데 여기선 JWT로 Authorization을 하므로 BasicAuthenticationFilter 오버라이딩한다.
private MemberRepository memberRepository;
public JwtAuthrizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
super(authenticationManager);
this.memberRepository = memberRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("인증이나 권한이 필요한 주소 요청됨");
String jwtHeader = request.getHeader("Authorization");
//헤더를 가져와 토큰을 가지고 있는지 체크함
if(jwtHeader==null || !jwtHeader.startsWith("Bearer")) { //시작이 Bearer가 아니거나 null일 경우
chain.doFilter(request,response); //다음 필터로 넘김
return;
}
String jwtToken = jwtHeader.replace("Bearer ","");
String username = JWT.require(Algorithm.HMAC512("jwt")).build().verify(jwtToken).getClaim("username").asString();
//토큰의 username 복호화해서 확인, 서비스에 등록한 유저인지 확인한다.
if(username != null) { //만약 유저네임이 존재한다면 유저 인증
Member member = memberRepository.findByUsername(username);
PrincipalDetails principalDetails= new PrincipalDetails(member);
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication); //저장함
chain.doFilter(request,response); //다음 필터로 넘어가기
}
super.doFilterInternal(request, response, chain);
//dofilterInternal()는 인증이나 권한이 필요한 주소 요청이 있을을 때마다 해당 필터를 통하게 되어있음
}
}
...
public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder
.addFilter(corsFilter)
.addFilter(new JwtAuthenticationFilter(authenticationManager))
.addFilter(new JwtAuthrizationFilter(authenticationManager, memberRepository));
}
}
@RequiredArgsConstructor
@RestController
public class RestApiController {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@GetMapping("/")
public String root() {
return "root";
}
@PostMapping("/join")
public String join(@RequestBody Member member) {
member.setPassword(bCryptPasswordEncoder.encode(member.getPassword()));
memberRepository.save(member);
return "회원가입완료";
}
@GetMapping("/api/v1/user")
public String user() {
return "user";
}
@GetMapping("/api/v1/admin")
public String admin() {
return "admin";
}
}
http://localhost:8080/join
http://localhost:8080/login
http://localhost:8080/api/v1/user/
의 요청 헤더에 login의 응답 헤더의 JWT 입력(Authorization, Bearer ey~)후 Get요청시 /user로 접근 할 수 있다.(/admin/**
은 403 forbidden이 뜬다)