Spring 게시판 02 - 로그인, 회원가입 만들기

yuns·2022년 10월 13일

JWT, 스프링 시큐리티 사용하여 게시판에 로그인, 회원가입 기능 구현하기

JWT

회원을 구분할 수 있는 정보가 담기는 곳이 JWT의 payload부분이며, 이곳에 담기는 정보의 한 조각을 Claim이라고 한다.
Claim은 name / value 한 쌍으로 이루어져 있다.

회원가입 만들기

build.gradle

스프링 시큐리티와 jwt에 사용되는 의존성 추가 후 gradle 새로고침하기

    //security
	implementation 'org.springframework.boot:spring-boot-starter-security'
 
	// jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

WebSecurityConfig.java

스프링 시큐리티 설정을 해주는 클래스

스프링 시큐리티를 사용하기 위해 Spring Security Filter Chain을 사용한다는 것을 명시해줘야하는데,
이것은 WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity annotation을 붙여주면 된다.

import com.sparta.BoardAPI2.jwt.JwtAuthenticationFilter;
import com.sparta.BoardAPI2.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    // 암호화에 필요한 PasswordEncoder 를 Bean 등록합니다.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // authenticationManager를 Bean 등록합니다.
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
                .csrf().disable() // csrf 보안 토큰 disable처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
                .and()
                .authorizeRequests() // 요청에 대한 사용권한 체크
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class);
        // JwtAuthenticationFilterUsernamePasswordAuthenticationFilter 전에 넣는다
    }
}

User.java

User 정보를 담을 Entity 객체 역할

import com.sparta.BoardAPI2.dto.UserRequestDto;
import lombok.*;

import javax.persistence.*;

@Entity //db테이블과 일대일로 매핑
@Table(name = "`user`") // 테이블명 지정
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @Id
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(length = 50, unique = true)
    private String username;

    @Column(length = 100)
    private String password;

    public User(UserRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.password = requestDto.getPassword();
    }
}

UserRepository.java

유저 정보를 DB에 저장하거나 찾는 역할

import com.sparta.BoardAPI2.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

UserRequestDto.java

회원가입에 필요한 정보 요청 클래스

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserRequestDto {
    private String username;

    private String password;

    private String passwordCheck;
}

UserService.java

회원가입 메소드
1. userRepository의 findByUsername으로 같은 유저네임이 있는지 찾기
2. BCrypt엔코더로 비밀번호 암호화
3. requestDto의 password를 암호화된 비밀번호로 set해주기
4. requestDto정보로 새 user객체 만들기
5. 만든 객체 userRepository에 저장하기

import com.sparta.BoardAPI2.dto.UserRequestDto;
import com.sparta.BoardAPI2.entity.User;
import com.sparta.BoardAPI2.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;
import java.util.regex.Pattern;

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    // 회원가입 조건 체크
    public boolean checkSignupValueCondition(UserRequestDto requestDto){
        String username = requestDto.getUsername();
        String password = requestDto.getPassword();
        String passwordCheck = requestDto.getPasswordCheck();

        boolean checkValueCondition = true;
        String pattern = "^[a-zA-Z0-9]*$";
        if( !(Pattern.matches(pattern,username) && username.length()>=4 && username.length()<=12) ){
            System.out.println("닉네임이 잘못되었습니다");
            checkValueCondition=false;
        }
        else if( !(Pattern.matches(pattern,password) && password.length()>=4 && password.length()<=32) ){
            System.out.println("비밀번호가 잘못되었습니다");
            checkValueCondition=false;
        }
        else if( !password.equals(passwordCheck) ){
            System.out.println("비밀번호 확인과 일치하지 않습니다");
            checkValueCondition=false;
        }
        return checkValueCondition;
    }

    // 회원가입
    public UserRequestDto register(UserRequestDto requestDto) {
        // 요구조건 확인
        if(!checkSignupValueCondition(requestDto)){
            throw new IllegalArgumentException("회원가입 정보가 정확하지 않습니다.");
        };

        //회원 닉네임 중복 확인
        Optional<User> found = userRepository.findByUsername(requestDto.getUsername());
        if(found.isPresent()){
            throw new IllegalArgumentException("중복된 사용자 id가 존재합니다.");
        }
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        requestDto.setPassword(passwordEncoder.encode(requestDto.getPassword())); ;
        User user = new User(requestDto);
        userRepository.save(user);
        return requestDto;
    }
}

UserController.java

import com.sparta.BoardAPI2.dto.UserRequestDto;
import com.sparta.BoardAPI2.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    // 회원가입
    @PostMapping("/signup")
    public UserRequestDto register (@RequestBody UserRequestDto requestDto) {
        userService.register(requestDto);
        return requestDto;
    }
}

POSTMAN 403 Fobbiden 에러

csrf 설정을 disable해준다.
(SecurityConfig.java)

        http
                // token을 사용하는 방식이기 때문에 csrf를 disable하게 설정
                .csrf().disable()

rawpassword cannot be null 에러

문제상황 : postman에서 signup api가 실행이 안됨
해결 : 매개변수에 @RequestBody 추가해서 데이터를 파싱시켜줘야함

    @PostMapping("/signup")
    public UserRequestDto register (@RequestBody UserRequestDto requestDto) {
        userService.register(requestDto);
        return requestDto;
    }

로그인 만들기

JwtTokenProvider

JWT 생성 및 유효성을 검증함

Claim정보에는 토큰에 부가적으로 실어 보낼 정보를 담을 수 있음

package com.sparta.BoardAPI2.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 = "asdfasdfasdfasdfasdfasdfasdfqwerqwerqwerqwerqwerqwer";

    // 토큰 유효시간 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) {
        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;
        }
    }
}

JwtAuthenticationFilter

  • 인증 / 인가 작업을 해주는 역할
  • 토큰을 생성하고 검증하는 컴포넌트인 TokenProvider을 이용한다.
  • 사용자가 로그인 정보로 인증 요청을 보내면, filter가 요청을 받아 인증용 객체 생성
  • 인증용 객체를 AuthenticationManager에게 보냄
  • 인증이 완료된 객체를 SecurityContext에 저장함
package com.sparta.BoardAPI2.jwt;
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);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

UserController.java

login시 token반환하는 메소드 추가
// 수정 : service로 로직 옮기기

    @PostMapping("/login")
    public String login(@RequestBody LoginRequestDto requestDto) {
        User member = userRepository.findByUsername(requestDto.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 회원입니다."));
        // 비밀번호 복호화 (passwordEncoder사용)
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        if (passwordEncoder.matches(member.getPassword(), requestDto.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }
        return jwtTokenProvider.createToken(member.getUsername());
    }

UserDetailsImpl.java

  • 스프링 시큐리티 UserDetails 객체로 권한 정보를 관리할 수 있게 한다.
import com.sparta.BoardAPI2.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

public class UserDetailsImpl implements UserDetails {
    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    public User getUser(){
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

UserDetailsServiceImpl.java

  • 데이터베이스에서 사용자 인증정보를 가져오는 역할
  • UserDetailsService 를 상속받는다.
  • 토큰에 저장된 유저 정보를 활용할 수 있게 한다
import com.sparta.BoardAPI2.entity.User;
import com.sparta.BoardAPI2.repository.UserRepository;
import lombok.AllArgsConstructor;
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
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User user =  userRepository.findByUsername(username)
                .orElseThrow(()->new UsernameNotFoundException("아이디가 존재하지 않습니다"));
        return new UserDetailsImpl(user);

    }
}

POSTMAN token 확인 테스트

로그인 후 토큰이 생성되면,
그 토큰을 이용해 유저네임을 출력하는 코드

    @GetMapping("/api/userinfo")
    @ResponseBody
    public String getUserInfo(@AuthenticationPrincipal UserDetailsImpl userDetails){
        if(userDetails!=null){
            System.out.println("로그인 된 상태입니다.");
            return userDetails.getUser().getUsername();
        }
        return "확인 불가";
    }


Headers에 가서,
Key : X-AUTH-TOKEN
Value : /login API 실행 시 나오는 토큰 값
를 입력해 준 뒤 send

유저이름(1234)가 정상적으로 출력된다


0개의 댓글