본문 바로가기

Development/Spring

[Spring] Spring Security + JWT: UserDetails와 로그인, 회원가입 구현하기

UserDetails

  • UserDetails는 Spring Security에서 사용자를 나타내는 인터페이스이다.
  • 사용자 이름, 비밀번호, 권한 등 사용자 인증 및 권한 부여에 필요한 정보를 제공한다.
  • Spring Security는 UserDetails 인터페이스를 구현한 클래스를 사용하여 사용자를 인증하고 권한을 부여한다.
    public interface UserDetails extends Serializable {
     Collection<? extends GrantedAuthority> getAuthorities();

     String getPassword();

     String getUsername();

     boolean isAccountNonExpired();

     boolean isAccountNonLocked();

     boolean isCredentialsNonExpired();

     boolean isEnabled();
    }
  • getPassword(): 사용자의 비밀번호를 반환
  • getUsername(): 사용자 이름을 반환
  • getAuthorities(): 사용자에게 부여된 권한을 반환
  • isAccountNonExpired(): 사용자 계정이 만료되었는지 확인
  • isAccountNonLocked(): 사용자 계정이 잠겨 있는지 확인
  • isCredentialsNonExpired(): 사용자의 비밀번호가 만료되었는지 확인
  • isEnabled(): 사용자 계정이 활성화되어 있는지 확인

Serializable 인터페이스

  • Serializable은 객체를 직렬화하여 바이트 배열로 변환하고, 이를 역직렬화하여 다시 객체로 복원할 수 있도록 하는 Java 인터페이스이다.
  • 객체를 네트워크를 통해 전송하거나 파일에 저장하는 데 사용된다.
    객체의 상태를 영구적으로 저장하거나 다른 JVM에서 사용할 수 있도록 한다.
  • Spring Security에서 Serializable을 사용하는 이유:
    사용자 세부 정보(UserDetails)를 세션에 저장하거나 HTTP 쿠키로 전송할 때 직렬화가 필요하다.
    Spring Security는 사용자 세부 정보를 인증 및 권한 부여에 사용하며, 이를 위해 Serializable 인터페이스를 사용하여 객체를 직렬화하고 역직렬화한다.

User 엔티티

  • UserDetails 를 구현한 User 엔티티는 다음과 같다.
@Entity
@Getter
@Builder
@Table
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String uid;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    @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());
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

@ElementCollection

  • @ElementCollection 어노테이션은 엔티티 클래스에 컬렉션 타입의 값을 저장하도록 하는 JPA 어노테이션이다.
  • @ElementCollection를 사용하면 컬렉션 값을 별도의 테이블에 저장하여 데이터베이스 정규화를 유지한다.

@Builder.Default:매개변수에 명시적으로 값을 지정하지 않아도 기본값을 사용할 수 있다.

getAuthorities()

    • Collection<GrantedAuthority> 타입의 객체를 반환하여 사용자의 권한 목록을 제공한다.
    • 스프링 시큐리티는 getAuthorities() 메서드를 통해 반환된 권한 정보를 기반으로 사용자 인증 및 권한 부여를 수행한다.
    • 각 권한은 GrantedAuthority 인터페이스를 구현하는 객체로 표현된다.

GrantedAuthority

  • GrantedAuthority 인터페이스는 Spring Security에서 사용되는 사용자 권한 정보를 표현하는 데 사용되는 인터페이스이다.
  • 기본 권한 객체로는 SimpleGrantedAuthority 클래스가 있다.

SimpleGrantedAuthority

public final class SimpleGrantedAuthority implements GrantedAuthority {
    private static final long serialVersionUID = 620L;
    private final String role;

    public SimpleGrantedAuthority(String role) {
        Assert.hasText(role, "A granted authority textual representation is required");
        this.role = role;
    }

    public String getAuthority() {
        return this.role;
    }

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (obj instanceof SimpleGrantedAuthority) {
            SimpleGrantedAuthority sga = (SimpleGrantedAuthority)obj;
            return this.role.equals(sga.getAuthority());
        } else {
            return false;
        }
    }

    public int hashCode() {
        return this.role.hashCode();
    }

    public String toString() {
        return this.role;
    }
}

SignService

  • 로그인, 회원가입 기능을 구현할 서비스이다.
public interface SignService {

    SignUpResultDto signUp(String id, String password, String name, String role);

    SignInResultDto signIn(String id, String password) throws RuntimeException;

}

SignUpResultDto 

  • 회원가입 성공 시 사용할 DTO 객체이다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {

    private boolean success;

    private int code;

    private String msg;

}

SignInResultDto

  • 로그인 성공 시 사용할 DTO 객체이다.
  •  SignUpResultDto를 상속받고, token을 갖고있다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto {

    private String token;

    @Builder
    public SignInResultDto(boolean success, int code, String msg, String token) {
        super(success, code, msg);
        this.token = token;
    }

}

PasswordEncoder

  • Spring Security는 PasswordEncoder 인터페이스를 제공한다.
  • PasswordEncoder는 다음과 같은 역할을 수행한다:
    비밀번호 해시화: 사용자 입력 비밀번호를 안전한 해시 값으로 변환한다. 해시 값은 비밀번호를 직접 저장하지 않고, 비교를 위해 사용된다.
    비밀번호 비교: 입력된 비밀번호의 해시 값을 저장된 해시 값과 비교하여 일치 여부를 확인한다.
    솔트 사용: 비밀번호 해시화 과정에서 솔트(salt)를 사용하여 브루트포스 공격(brute-force attack)의 어려움을 높인다. 솔트는 각 사용자마다 고유하게 생성되는 임의의 값이다.
@Configuration
public class PasswordEncoderConfiguration {

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

 

  • PasswordEncoderFactories.createDelegatingPasswordEncoder(): 이 코드는 PasswordEncoderFactories 클래스의 createDelegatingPasswordEncoder() 메소드를 호출하여 PasswordEncoder Bean을 생성한다.
  • DelegatingPasswordEncoder란, Spring Security에서 여러 해시 알고리즘을 지원하는 유연한 비밀번호 Encoder로 다음과 같은 역할을 한다:
    다양한 해시 알고리즘 지원: BCrypt, Pbkdf2, SCrypt 등 다양한 해시 알고리즘을 지원한다.
    자동 알고리즘 선택: 요청에 따라 적절한 해시 알고리즘을 자동으로 선택한다.

CommonResponse

  • CommonResponse는 성공 또는 실패 시 설정할 코드와 메세지 정보를 포함하는 enum 클래스이다.
  • enum 클래스는 열거형 상수를 정의하는 데 사용되는 특수한 클래스이다.
package org.spring.study.common;

import lombok.Getter;

public enum CommonResponse {

    SUCCESS(0, "Success"), FAIL(-1, "Fail");

    int code;
    String msg;

    CommonResponse(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

}

회원가입 메서드 구현하기

  • signUp 메서드는 회원가입 기능을 구현한다.
@Override
    public SignUpResultDto signUp(String id, String password, String name, String role) throws RuntimeException {
        LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");
        User user;
        if (role.equalsIgnoreCase("admin")) {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_ADMIN"))
                    .build();
        } else {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_USER"))
                    .build();
        }

        User savedUser = userRepository.save(user);
        SignUpResultDto signUpResultDto = new SignUpResultDto();

        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
        if (!savedUser.getName().isEmpty()) {
            LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setSuccessResult(signUpResultDto);
        } else {
            LOGGER.info("[getSignUpResult] 실패 처리 완료");
            setFailResult(signUpResultDto);
        }
        return signUpResultDto;
    }
  • role 파라미터 값에 따라 User 객체의 roles를 설정한다.
  • User.builder()를 사용하여 사용자 정보를 담은 객체 생성한다.
  • passwordEncoder 객체를 사용하여 입력된 비밀번호를 해시화한다.
  • userRepository 를 사용하여 사용자 정보를 데이터베이스에 저장한다.
  • SignUpResultDto 객체를 생성하여 리턴한다.

equalsIgnoreCase

  • equalsIgnoreCase 메서드는 두 문자열을 비교할 때 대소문자를 무시하고 일치 여부를 확인하는 메서드이다.

Collections.singletonList

  • 단일 요소로 구성된 리스트를 생성하며, 이때 생성된 리스트는 불변이다.
  • 따라서 요소를 추가하거나 제거할 수 없다.
  • 멀티스레드 환경에서 안전하게 사용할 수 있다.

로그인 메서드 구현하기

  • signIn 메서드는 로그인 기능을 수행한다.
 @Override
    public SignInResultDto signIn(String id, String password) throws RuntimeException {
        LOGGER.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
        User user = userRepository.getByUid(id);
        LOGGER.info("[getSignInResult] Id : {}", id);

        LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException();
        }
        LOGGER.info("[getSignInResult] 패스워드 일치");

        LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
        SignInResultDto signInResultDto = SignInResultDto.builder()
            .token(jwtTokenProvider.createToken(String.valueOf(user.getUid()),
                user.getRoles()))
            .build();

        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        setSuccessResult(signInResultDto);

        return signInResultDto;
    }
  • 사용자 정보 요청: User user = userRepository.getByUid(id); 이 부분에서는 사용자가 입력한 ID를 이용해 사용자 정보를 데이터베이스에서 가져온다.
  • 패스워드 비교: if (!passwordEncoder.matches(password, user.getPassword())) 이 부분에서 사용자가 입력한 패스워드와 데이터베이스에 저장된 패스워드를 비교한다. 패스워드가 일치하지 않으면 RuntimeException을 발생시킨다.
  • 토큰 생성: 로그인에 성공한 사용자에 대해 JWT(Json Web Token)를 생성한다. 이 토큰은 사용자의 ID와 역할 정보를 포함한다.
  • 로그인 결과 반환: 마지막으로, 생성된 토큰을 포함하는 SignInResultDto 객체를 반환한다.

참고