[백업] 로그인과 JWT 발급

박솔찬·2022년 6월 12일
1
post-custom-banner

로그인 및 토큰 발급

+ 이 글을 작성하던 당시(2021년 8월 31일) 비교적 적은 스프링부트에 대한 지식을 바탕으로 작성된 게시글입니다.

전체적인 로그인 및 JWT 토큰 발급 플로우만 확인 후, 코드를 클린하게 수정하여 작성하시길 바랍니다.

스프링 시큐리티를 활용하여 로그인을 진행하고, 로그인이 성공적으로 진행되면 JWT를 발급하여 헤더에 응답해보자.

진행은 우선 회원가입을 진행하여 H2 DataBase(메모리)에 정보를 저장하고, 로그인을 진행하여 검증에 성공하면 jwt토큰을 발급하여 헤더에 authorization키로 응답하는 것으로 마무리한다.

App 및 Security Config 생성

회원가입시 비밀번호 암호화를 진행해야 한다.
따라서 컨테이너에 PasswordEncoder를 Bean으로 등록해야 한다.

@Configuration
public class AppConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

@Configuration을 통해 설정파일로 등록하고, @Bean을 통해 PasswordEncoder를 등록한다.

다음으로, Spring Security를 사용하기 위해서 이에 대한 설정도 필요하다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable() // csrf 비활성화
                .authorizeRequests() 
                .antMatchers("/join", "/login").permitAll() // 검증 없이 접근 허용
                .antMatchers("/admin/**").hasRole("ADMIN") // ADMIN권한 접근 가능
                .antMatchers("/user/**").hasRole("USER") // USER권한 접근 가능
                .anyRequest().authenticated();
    }
}

csrf를 비활성화 하여 Post 요청이 가능하도록 한다.
csrf는 특정 웹사이트에 공격 방법 중 하나이고, 여기서 설정하는 csrf는 이를 방지하기 위해 csrf 토큰을 통해 인증을 하는 것이다. 웹을 통한 로그인이 목적이 아니기 때문에 비활성화를 하였다.
csrf 공격과 방어

join과 login URI에 대해서는 검증 없이 요청을 허용하도록 설정하였다.

회원가입

로그인을 진행하기 위해서는 우선 회원가입이 되어있어야 한다.

다음의 패키지 구조와 디펜던시를 포함한 프로젝트로 진행한다.

|-main
|-|
|-|-java
|-|-|-app
|-|-|-|
|-|-|-|-config
|-|-|-|-|-AppConfig
|-|-|-|-|-SecurityConfig
|-|-|-|-domain
|-|-|-|-|-User
|-|-|-|-|-UserDTO
|-|-|-|-|-UserRepository
|-|-|-|-controller
|-|-|-|-|-UserController
|-|-|-|-service
|-|-|-|-|-UserService
|-|-|-|-jwt
|-|-|-|-|-JwtTokenProvider

dependencies

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'
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

User 도메인 과 DTO 생성

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {

    // Auto Increment PK
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // Email
    @Column(nullable = false, unique = true)
    private String email;

    // Password
    @Column(nullable = false)
    private String password;

    // Roles
    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();
}

유저의 아이디, 이메일, 비밀번호 그리고 권한 설정이 가능하도록 정의한다.

@ElementCollection은 RDB에 컬렉션 등의 저장을 가능하게 하도록 JPA에게 알려 One To Many의 테이블을 생성하고 관리해주는 역할을 한다.
자세한 설명은 다음 글을 참고하자.
@ElementCollection이란?

UserDTO는 회원가입 요청과 로그인 요청시 데이터를 받기위한 객체이다.

@Getter
public class UserDTO {
    // 토큰 발급 및 인증 학습이 목적이기 때문에
    // 검증 조건은 일단 생략..
    private String email;
    private String password;
}

email과 password의 검증을 어노테이션을 통해 설정할 수 있으나, 현재는 jwt발급과 인증이 주 목적이기 때문에 생략하였다.

User Repository 생성

public interface UserRepository extends JpaRepository<User, Long> {
    User save(User account);
    Optional<User> findByEmail(String email);
}

JpaRepository를 상속받는 인터페이스를 정의한다.
유저 정보를 검증하고 저장하기 위해서 save와 findByEmail을 정의한다.

Controller와 Service 생성

Controller

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // 회원가입
    @PostMapping("/join")
    public ResponseEntity join(@RequestBody UserDTO user) {
        Integer result = userService.join(user);
        return result != null ?
                ResponseEntity.ok().body("회원가입을 축하합니다!") :
                ResponseEntity.badRequest().build();
    }
}

요청이 오면 서비스의 join메서드를 호출하고, 가입 결과를 응답합니다.

Service

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public Integer join(UserDTO user) {
        Integer userId = userRepository.save(
                User.builder()
                        .email(user.getEmail())
                        .password(passwordEncoder.encode(user.getPassword()))
                        .roles(Collections.singletonList("ROLE_USER"))
                        .build())
                .getId();
        return userId;
    }
}

JWT발급 및 인증이 목적이기 때문에 이메일 중복과 같은 검증은 생략하였다.
Builder를 사용하였고, 이메일과 비밀번호 그리고 권한을 설정하고 ID를 반환하도록 하였다.

이제 다음과 같이 회원가입을 진행 해볼수 있다.

[##Image|kage@PCjZc/btrs2EWeyVP/CvkUUXReuI9IEfZdkJJ641/img.png|CDM|1.3|{"originWidth":808,"originHeight":630,"style":"alignCenter"}##]

이제는 가입된 정보로 로그인을 요청하고 JWT발급을 확인해보자

로그인

JwtTokenProvider 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    // 키
    private String secretKey = "lalala";

    // 토큰 유효시간 | 30min
    private long tokenValidTime = 30 * 60 * 1000L;

    // 의존성 주입 후, 초기화를 수행
    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT Token 생성.
    public String createToken(String user, List<String> roles){
        Claims claims = Jwts.claims().setSubject(user); // claims 생성 및 payload 설정
        claims.put("roles", roles); // 권한 설정, key/ value 쌍으로 저장

        Date date = new Date();
        return Jwts.builder()
                .setClaims(claims) // 발행 유저 정보 저장
                .setIssuedAt(date) // 발행 시간 저장
                .setExpiration(new Date(date.getTime() + tokenValidTime)) // 토큰 유효 시간 저장
                .signWith(SignatureAlgorithm.HS256, secretKey) // 해싱 알고리즘 및 키 설정
                .compact(); // 생성
    }

    // TODO 토큰에서 인증정보 조회

    // TODO 토큰에서 회원정 추출

    // TODO 요청 헤더에서 토큰 추출

    // TODO 토큰의 유효성 확인
}

createToken메서드를 통해 로그인 검증이 성공한 경우, 토큰을 발급해준다.
토큰은 발행 유저 정보, 발행 시간, 유효 시간, 그리고 해싱 알고리즘과 키를 설정하여 발행한다.

Controller와 Service 작성

Controller

@RestController
@RequiredArgsConstructor
public class UserController {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;

    // 회원가입
    @PostMapping("/join")
    public ResponseEntity join(@RequestBody UserDTO user) {
        Integer result = userService.join(user);
        return result != null ?
                ResponseEntity.ok().body("회원가입을 축하합니다!") :
                ResponseEntity.badRequest().build();
    }

    // 로그인
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody UserDTO user, HttpServletResponse response) {
        // 유저 존재 확인
        User member = userService.findUser(user);
        boolean checkResult = userService.checkPassword(member, user);
        // 비밀번호 체크
        if(!checkResult) {
            throw new IllegalArgumentException("아이디 혹은 비밀번호가 잘못되었습니다.");
        }
        // 토큰 생성 및 응답
        String token = jwtTokenProvider.createToken(member.getEmail(), member.getRoles());
        response.setHeader("authorization", "bearer " + token);
        return ResponseEntity.ok().body("로그인 성공!");

    }

    // 통합 예외 핸들러
    @ExceptionHandler
    public String exceptionHandler(Exception exception) {
        return exception.getMessage();
    }
}

Service

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public Integer join(UserDTO user) {
        Integer userId = userRepository.save(
                User.builder()
                        .email(user.getEmail())
                        .password(passwordEncoder.encode(user.getPassword()))
                        .roles(Collections.singletonList("ROLE_USER"))
                        .build())
                .getId();
        return userId;
    }

    public User findUser(UserDTO user) {
        User member = userRepository.findByEmail(user.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("아이디 혹은 비밀번호가 잘못되었습니다."));
        return member;
    }

    public boolean checkPassword(User member, UserDTO user) {
        return passwordEncoder.matches(user.getPassword(), member.getPassword());
    }
}

controller의 login메서드로 매핑되면, 아이디와 비밀번호 검증을 진행한다.
검증에 성공하면 jwtTokenProvider를 호출하여 토큰을 발급받는다.
발급받은 토큰을 응답 헤더에 authorization키로 넣어 응답한다.

로그인 진행

다음과 같이 로그인을 진행하면,

[##Image|kage@diX8gb/btrs4M0iNGd/fybfqOJ0jGWFOkz7lzXvE0/img.png|CDM|1.3|{"originWidth":1280,"originHeight":744,"style":"alignCenter"}##]

응답 헤더의 authorization키의 값으로 토큰이 오는 것을 확인할 수 있다.

profile
Why? How? What?
post-custom-banner

0개의 댓글