슬기로운 개발생활

Spring Boot JPA 게시판 Security 회원가입,로그인 구현

by coco3o
반응형

예전에는 회원가입 및 로그인을 전통적인 방식으로 구현했지만 요즘은 사용하지 않는 추세이고,

Spring Security와 OAuth 2.0을 사용한다.

Spring Security ? 스프링 기반의 어플리케이션에서 보안을 위해 인증권한 부여를 사용해 접근을 제어하는 프레임워크이다.

OAuth 2.0에 대해 궁금하다면 여기서 참고하면 될 것 같다. 이해하기 쉽게 설명을 잘해주셨다.

이제 Security를 이용해 회원가입 및 로그인을 구현해보자.


1. build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'

2. User , Role , UserRepository

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

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

    @Column(nullable = false, length = 30, unique = true)
    private String username; // 아이디
    
    @Column(nullable = false)
    private String nickname;

    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false, length = 50)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;
}

User의 username은 id이기 때문에 unique 속성을 추가로 넣어줬다.

@Getter
@RequiredArgsConstructor
public enum Role {
    USER("ROLE_USER"),
    ADMIN("ROLE_ADMIN");

    private final String value;
}
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

Username을 where 조건절에 넣어 데이터를 가져올 수 있도록 findByUsername 정의했다.


3. UserDto

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDto {

    private String username;

    private String password;

    private String nickname;

    private String email;

    private Role role;

    /* DTO -> Entity */
    public User toEntity() {
        User user = User.builder()
                .username(username)
                .password(password)
                .nickname(nickname)
                .email(email)
                .role(role.USER)
                .build();
        return user;
    }
}

4. SecurityConfig 생성

Spring Security에서 WebSecurityConfigurerAdapter를 상속받은 클래스에서 메소드를 오버라이딩하여 조정할 수 있다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity 
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public BCryptPasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService).passwordEncoder(encoder());
    }
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web
                .ignoring().antMatchers( "/css/**", "/js/**", "/img/**");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/", "/auth/**", "/posts/read/**", "/posts/search/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .loginPage("/auth/login")
                .loginProcessingUrl("loginProc")
                .defaultSuccessUrl("/")
                .and()
                .logout()
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true);
    }
}
  • @RequiredArgsConstructor
    • CustomUserDetailsService 생성자 주입을 위한 lombok
  • @EnableWebSecurity
    • @Configuration에 @EnableWebSecurity를 추가해 Spring Security 설정 클래스임을 알려준다.
  • @EnableGlobalMethodSecurity(prePostEnabled = true) 
    • 특정 주소로 접근하면 권한 및 인증을 미리 체크하기 위해 사용
  • encoder()
    • BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체이다.
    • 비밀번호를 암호화해서 사용할 수 있도록 Bean으로 등록한다.
  • configure(AuthenticationManagerBuilder auth)
    • Spring Security에서 모든 인증처리는 AuthenticationManager를 통해 이루어지는데, AuthenticationManager를 생성하기 위해서 AuthenticationManagerBuilder를 사용해 생성한다.
    • 로그인 인증을 위해 MyUserDetailsService에서 UserDetailsService를 implements하여 loadUserByUsername() 메소드를 구현했다.
    •  그리고 AuthenticationManager에게 어떤 해쉬로 암호화했는지 알려주기 위해 passwordEncoder를 사용했다.
  • configure(WebSecurity web)
    • 인증을 무시할 경로를 설정해준다.
    • static 하위 폴더 (css, js, img)는 무조건 접근이 가능해야하기 때문에 인증을 무시하게 해 주었다.
  • configure(HttpSecurity http)
    • HttpSecurity를 통해 HTTP 요청에 대한 보안을 구성할 수 있다.
    • csrf().disable()
      • Spring Security에서는 csrf 토큰 없이 요청하면 해당 요청을 막기 때문에 잠깐 비활성화해주었다.
    • authorizeRequests()
      • HttpServletRequest에 따라 접근을 제한한다.
      • antMatchers() 메소드로 특정 경로를 지정하며, permitAll(),hasRole() 메소드로 권한에 따른 접근 설정을 한다. 예를 들면,
        • .antMatchers("/admin/**").hasRole("ADMIN")
          • /admin 으로 시작하는 경로는 ADMIN 권한을 가진 사용자만 접근이 가능하다.
        • .antMatchers("/user/**").hasRole("USER")
          • /user 로 시작하는 경로는 USER 권한을 가진 사용자만 접근이 가능하다.
        • .antMatchers("/", "/auth/**", "/posts/read/**", "/posts/search/**").permitAll()
          • 위 경로에 대해 권한 없이 접근이 가능하다.
        • .anyRequest().authenticated()
          • 그 외의 경로는 인증된 사용자만이 접근이 가능하다.
      • formLogin()
        • form 기반으로 인증한다. /login 경로로 접근하면, Spring Security에서 제공하는 로그인 Form을 사용할 수 있다.
        • .loginPage("/auth/login")
          • 기본으로 제공되는 form 말고, 커스텀 로그인 폼을 사용하기 위해 loginPage() 메소드를 사용했다.
        • .loginProcessingUrl("/loginProc")
          • Security에서 해당 주소로 오는 요청을 낚아채서 수행한다.
        • .defaultSuccessUrl("/")
          • 로그인 성공 시 이동되는 페이지이다.
      • logout()
        • 로그아웃을 지원하는 메소드이며, WebSecurityConfigureAdapter를 사용하면 자동으로 적용된다.
        • 기본적으로 "/logout"에 접근하면 HTTP 세션을 제거해준다. 필자는 그냥 명시적으로 작성했다.
        • .logoutSuccessUrl("/")
          • 로그아웃 성공시 이동되는 페이지이다.
        • .invalidateHttpSession(true)
          • HTTP 세션을 초기화하는 작업이다.

5. 세션정보 저장 dto 클래스

@Getter
public class UserSessionDto implements Serializable {
    private String username;
    private String password;
    private String nickname;
    private String email;
    private Role role;
	
    /* Entity -> Dto */
    public UserSessionDto(User user) {
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.nickname = user.getNickname();
        this.email = user.getEmail();
        this.role = user.getRole();
    }
}

인증된 사용자 정보를 세션에 저장하기 위한 클래스이다.

User 엔티티 클래스에 직접 세션을 저장하려면 직렬화를 해야 하는데,
엔티티 클래스에 직렬화를 해준다면 추후에 다른 엔티티와 연관관계를 맺을 시 직렬화 대상에 다른 엔티티까지 포함될 수 있어
성능 이슈, 부수 효과 우려가 있다. -스프링 부트와 AWS로 혼자 구현하는 웹 서비스 中-


6. UserDetailsService와 UserDetails 구현 클래스

@RequiredArgsConstructor
@Component
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    private final HttpSession session;

    /* username이 DB에 있는지 확인 */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username).orElseThrow(() ->
                new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username));

        session.setAttribute("user", new UserSessionDto(user));

        /* 시큐리티 세션에 유저 정보 저장 */
        return new CustomUserDetails(user);
    }
}

UserDetailsService의 loadUserByUsername 메소드 오버라이딩 후 구현

/*
* 스프링 시큐리티가 로그인 요청을 가로채 로그인을 진행하고 완료 되면 UserDetails 타입의 오브젝트를
* 스프링 시큐리티의 고유한 세션저장소에 저장 해준다.
* */
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public String getPassword() { return user.getPassword(); }

    @Override
    public String getUsername() { return user.getUsername(); }

    /* 계정 만료 여부
     *  true : 만료 안됨
     *  false : 만료
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /* 계정 잠김 여부
     *  true : 잠기지 않음
     *  false : 잠김
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /* 비밀번호 만료 여부
     *  true : 만료 안됨
     *  false : 만료
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /* 사용자 활성화 여부
    *  true : 만료 안됨
    *  false : 만료
    */
    @Override
    public boolean isEnabled() {
        return true;
    }

    /* 유저의 권한 목록 */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collectors = new ArrayList<>();

        collectors.add(() -> "ROLE_"+user.getRole());

        return collectors;
    }
}

/* 주석 참고 */


7. Service

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

    private final BCryptPasswordEncoder encoder;

    @Transactional
    public Long join(UserDto dto) { 
        dto.setPassword(encoder.encode(dto.getPassword()));
        
        return userRepository.save(dto.toEntity()).getId();
    }

}

사용자 비밀번호를 해쉬 암호화 후 레파지토리에 저장한다.


8. Controller

/**
 * 화면 연결 Controller
 */

@Controller
public class UserIndexController {

    @GetMapping("/auth/join")
    public String join() { return "/user/user-join"; }
    
    @PostMapping("/auth/joinProc")
    public String joinProc(UserDto userDto) {
        userService.userJoin(userDto);

        return "redirect:/auth/login";
    }

    @GetMapping("/auth/login")
    public String login() { return "/user/user-login"; }
}

/auth/** 경로에 대해 권한 없이 접근 가능하도록 설정해뒀기에 각 url에 /auth/를 붙여주었다.

...
UserSession user = (UserSession) session.getAttribute("user");
   if (user != null) {
       model.addAttribute("user", user.getNickname());
   }
...

CustomUserDetailsService에서 세션정보를 저장하고 PostsIndexController 클래스에 가져와 각각 model에 담았다.


9. Mustache

header.mustache

    <div class="text-right">
        {{#user}}
            <span class="mx-3">{{user}}님 안녕하세요!</span>
            <a href="/logout" class="btn btn-outline-dark">로그아웃</a>
            <a href="/" class="btn btn-outline-dark bi bi-gear"></a>
        {{/user}}
        {{^user}}
            <a href="/auth/login" role="button" class="btn btn-outline-dark bi bi-person-circle"> 로그인</a>
            <a href="/auth/join" role="button" class="btn btn-outline-dark"> 회원가입</a>
        {{/user}}
    </div>

세션 유무에 따라 로그인 or 로그아웃을 할 수 있도록 했다.

join.mustache

{{>layout/header}}
<div id="posts_list">
    <div class="container col-md-8">
    
        <form action="/auth/joinProc" method="post">
            <div class="form-group">
                <label>아이디</label>
                <input type="text" name="username" class="form-control" placeholder="아이디를 입력해주세요">
            </div>

            <div class="form-group">
                <label>비밀번호</label>
                <input type="password" name="password" class="form-control" placeholder="비밀번호를 입력해주세요">
            </div>
            
            <div class="form-group">
                <label>닉네임</label>
                <input type="text" name="nickname" class="form-control" placeholder="닉네임을 입력해주세요">
            </div>
            
            <div class="form-group">
                <label>이메일</label>
                <input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요">
            </div>

            <button class="btn btn-primary bi bi-person"> 가입</button>
            <a href="/" role="button" class="btn btn-info bi bi-arrow-return-left"> 목록</a>
        </form>
    </div>
</div>
{{>layout/footer}}

login.mustache

{{>layout/header}}
<div id="posts_list">
    <div class="container col-md-6">
        <form action="/loginProc" method="post">
            <div class="form-group">
                <label>아이디</label>
                <input type="text" class="form-control" name="username" placeholder="아이디를 입력해주세요">
            </div>

            <div class="form-group">
                <label>비밀번호</label>
                <input type="password" class="form-control" name="password" placeholder="비밀번호를 입력해주세요">
            </div>

            <button class="btn btn-primary bi bi-person"> 로그인</button>
            <a href="/" role="button" class="btn btn-info bi bi-arrow-return-left"> 목록</a>
        </form>
    </div>
</div>
{{>layout/footer}}


Spring Security에서 데이터 전달은 Form을 사용 (name 값으로 데이터 전달)
JSON으로 데이터 전달 시 label for와 id를 사용 (헷갈리지 않기!)


10. 결과 확인

10-1. 미 로그인 사용자 화면

메인
검색
게시글 상세보기

인증되지 않은 사용자메인화면, 검색, 상세보기만 가능하며, 글의 수정, 삭제 또한 불가능하다.

인증되지 않은 사용자가 글쓰기를 누르면 다음과 같이 로그인 화면으로 이동시킨다.

로그인 화면

10-2. 회원가입 및 로그인 사용자 화면

회원가입

회원가입이 완료되면 로그인 화면으로 이동시키고 로그인이 완료되면 메인화면으로 이동한다.

인증된 사용자는 글쓰기가 가능하다.

글쓰기 후 메인화면
인증된 사용자 상세보기

수정, 삭제 또한 가능하다.


마무리하며

여기까지 일단 Spring Security로 회원가입과 로그인을 구현했다.

하지만 아직 보완해야 될 부분들이 몇 가지 있다.

1. csrf 문제

- Spring Security에서 csrf 토큰 없이 요청하면 그 요청을 막아버린다. 임시로 csrf().disable()을 사용해 비활성화했지만, 이는 완전히 해결된 것은 아니다. 필자는 mustache를 템플릿 엔진으로 사용하고 있는데, mustache는 csrf 토큰을 기본적으로 제공해주지 않기 때문에 참 난감하다.
>>해결 ( 포스트 보러 가기 )


2. 세션 중복 코드 개선

- 다음은 로그인 완료 시 세션정보를 가져와 보여주는 코드이다.

UserSession user = (UserSession) session.getAttribute("user");

Controller의 각 메소드마다 위 코드가 반복되고 있다. 이는 추후에 수정이 필요할 경우 모든 부분을 하나씩 수정해야 할 것이다.
이렇게 될 경우 유지보수성이 떨어지고, 다른 문제가 생길 수도 있을 것이다.
>>해결 ( 포스트 보러 가기 )


3. 유효성 검사 및 중복 검사

- 사용자가 회원가입 페이지에서 입력한 데이터 값이 서버로 전송되기 전에 특정 규칙에 맞게 입력되었는지,
가입하려는 아이디가 이미 존재하는지 등 확인하는 검증 단계가 반드시 필요할 것이다.
>>해결 ( 유효성 검사 , 중복 검사 )


4. 에러 메시지 출력

- 만약 로그인 페이지 상태에서 잘못된 로그인 정보를 입력한다고 치면, 로그인에 실패했지만 아무런 에러 메시지를 보지 못하고 로그인 페이지만 보일 것이다. 로그인이 실패했으면 어떤 이유로 실패했는지 에러 메시지를 띄워주어야 한다고 생각한다.
그래서, AuthenticationFailureHandler를 구현했지만, SPRING_SECURITY_LAST_EXCEPTION의 키값을 머스테치에 어떻게 넘겨줘야 될지 모르겠다..

>>해결 ( 포스트 보러 가기 )


+++2022/05/21 추가+++

4-1. UserDetailsService 세션 관련 문제

- 한동안 바빠서 확인해보지 못했는데, 오늘 나에게도 위와 같은 문제가 있었다는 것을 알았다.

필자는 로그인 실패 처리를 담당하는 CustomAuthFailureHandler 클래스의 메소드에 session.invalidate를 넣어주는 방법으로 우선 해결하였다.

(이 방법은 절대 best practice가 아님)

CustomAuthFailureHandler 관련 포스팅은 여기 에서 볼 수 있다.

당장 생각나는 해결 방법으로는 AuthenticationProvider를 커스터마이징하여 로그인 시 입력했던 id, pw와 UserDetails에서 가져온 User 객체의 id, pw를 비교 후 로그인에 성공했을 때 세션을 설정하는 방법인 것 같다.

생각지 못했던 문제를 짚어주신 user님 감사합니다. (__)

반응형

블로그의 정보

슬기로운 개발생활

coco3o

활동하기