SpringSecurity를 이용하여 로그인 처리를 해보자
Spring Security를 이용하여 제대로 된 로그인 처리를 해보자

0. 정책

  • 회원가입이후 회원가입이 정상적으로 완료된 것이 아니라 이후에 이메일 인증이 추가적으로 필요하다
  • 이메일 인증이 완료되어야만 정상적으로 서비스를 이용할 수 있다
  • 비밀번호가 5회이상 틀릴시 계정이 잠긴다

1. WebSecurity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomAuthenticationProvider authProvider;

    private final AuthSuccessHandler authSuccessHandler;
    private final AuthFailureHandler authFailureHandler;

    private final WebAccessDeniedHandler webAccessDeniedHandler;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/login","/js/*", "/css/*", "/img/*", "/test/failAuth", "/test/failSso","/verify/*").permitAll() // 게스트(비회원)이 들어갈 수 있는 페이지
                .antMatchers("/email").hasAnyRole("NOT_PERMITTED","USER") // NOT_PERMITTED = 회원가입은 했지만 이메일인증을 안한 회원, USER = 회원가입 + 이메일인증 절차가 완료된 회원
                .anyRequest().hasRole("USER") //나머지 페이지는 회원만 사용가능
                .and()
                .formLogin() //Security에서 제공해주는 LoginProcess를 사용한다
                .loginPage("/login")
                .successHandler(authSuccessHandler)
                .failureHandler(authFailureHandler)
                .usernameParameter("loginId")
                .passwordParameter("loginPassword")
                .and()
                .logout()
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll()
                .and()
                .exceptionHandling()
                .accessDeniedHandler(webAccessDeniedHandler);
    }

    /**
     * 인증/인가 확인 프로세서 주입
     * @param auth the {@link AuthenticationManagerBuilder} to use
     * @throws Exception
     * @see CustomAuthenticationProvider
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authProvider);
    }

}
  • formLogin(): 스프링 시큐리티가 제공하는 로그인 인증 방식을 사용한다
  • loginPage("/login"): resource 폴더에 있는 login.html을 사용한다
  • 로그인시 JSESSIONID쿠키에 저장되기때문에 쿠키도 삭제해준다
    login.html

      <body>
        <div class="form" id="form">
            <div id="unableLogin" role="alert">
                이메일과 비밀번호를 다시 확인해주세요.
            </div>
            <div class="field email">
                <div class="icon"></div>
                <input class="input" id="loginId" name="loginId" type="text" placeholder="id" autocomplete="off"/>
            </div>
            <div class="field password">
                <div class="icon"></div>
                <input class="input" id="loginPassword" name="loginPassword" type="password" placeholder="Password"/>
            </div>
            <button class="button" id="submit">LOGIN</button>
            <small>Fill in the form</small>
        </div>
      </body>
    
    • form태그를 사용하지않고 button을 이용하여 js로 백엔드 서버에게 전달한다
    • 여기서 사용되는 id="loginId" name="loginId"id="loginPassword" name="loginPassword"이 부분과 WebSecurityConfig.configure에서의
        http
        ...
        .usernameParameter("loginId")
        .passwordParameter("loginPassword")
        ...
      

      이렇게 매치를 시켜주어야한다

    login.js

      $submit.click(function() {
          $('#unableLogin').hide();
    
          $.ajax({
              url: '/login',
              type: 'post',
              dataType: 'json',
              //contentType: 'application/json',
              data: {
                  loginId: $('#loginId').val(),
                  loginPassword: $('#loginPassword').val()
              },
              success : function(result){
                  $("#unableLogin").text(result);
              }, error : function(request, errorcode, error){
                  const errorMessage = request.responseText;
                  document.getElementById("unableLogin").innerHTML=errorMessage;
                  //$('#unableLogin').text(errorMessage);
              },
              statusCode: {
                  200: function () {
                      $('#unableLogin').hide();
                      //비회원이 기존 사용하려는 서비스 페이지로 redirect해야함
                  },
                  401: function () {
                      $('#unableLogin').show(2000);
                  },
                  405: function () {
                      $('#unableLogin').show(2000);
                  },
                  404: function () {
                      $('#unableLogin').show(2000);
                  },
                  400: function () {
                      $('#unableLogin').show(2000);
                  },
                  409: function () {
                      $('#unableLogin').show(2000);
                  }
              }
    
          });
      });
    
    • form방식으로 전송하는 것이 아니기때문에 ajax를 통하여 post방식으로 /login으로 보낸다
    • 이 때의 /login의 RequestMapping은 SpringSecurity가 만들어놓은 것을 사용한다
  • invalidateHttpSession
    • 스프링 시큐리티가 로그인/로그아웃 처리방식을 HttpSession을 기본으로 한다
    • 브라우저를 종료하지 않을 때, 로그아웃을 행해서 자신이 로그인 했던 모든 정보를 삭제한다
  • exceptionHandling : 인가예외 작동
  • accessDeniedHandler(webAccessDeniedHandler): 사용자가 권한없는 페이지에 들어갔을때 Handling, 즉. 인가실패시 처리

cf. ROLE

사용자의 권한 종류

1
2
3
public enum Role {
    ROLE_NOT_PERMITTED, ROLE_USER, ROLE_MANAGER, ROLE_ADMIN
}
  • ROLE_NOT_PERMITTED: 이메일 인증안된 유저
  • ROLE_USER: 이메일 인증이 완료된 유저
  • ROLE_ADMIN: 운영자

동작순서

  1. 스프링이 제공하는 formlogin()을 사용하면 인증 필터인 UsernamePasswordAuthenticationFilter를 사용한다
    • 만약 토큰 기반이나 다른 방식으로 직접 필터를 만들거면 UsernamePasswordAuthenticationFilter를 상속받아 직접 필터를 만든다
  2. 사용자가 로그인 페이지를 통하여 아이디와 비밀번호를 입력한다.(AntPathRequestMatcher가 요청 정보의 url이 해당값인지 확인하고 로직을 시작한다)
    • url은 .loginProcessingUrl(“/login")로 설정한다. 기본값: /login
  3. 사용자가 로그인 버튼을 클릭하면 Username과 password정보가 담긴 Authentication객체를 생성하여 AuthenticationManager에 넘긴다
  4. AuthenticationManager는 이전에 받은 Authenticaton객체를 AuthenticationProvider 에 넘겨준다
  5. AuthenticationProvider는 실제로 로그인처리를 하도록 해준다
    • Authenticaton에서 사용자가 입력한ID 필드값을 통해 DB에 있는 사용자 정보(=UserDetails)를 가져와 검증한다
    • 로그인 인증이 성공할 경우 최종 Authentication와 함께 AuthenticationSuccessHandler를 통해 성공 핸들러 처리가 된다
    • 로그인 인증이 실패할 경우 AuthenticationFailureHandler를 통해 실패 핸들러 처리가 된다
  6. 참고로 최종 AuthenticationSpring Context에 저장된다

각각 구현체
AuthSuccessHandler -> AuthenticationSuccessHandler
AuthFailureHandler -> AuthenticationFailureHandler
AuthenticationProvider -> CustomAuthenticationProvider
AccessDeniedHandler -> WebAccessDeniedHandler
UserDetails -> Member

2. AuthenticationProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {


    private final UserDetailsService customUserDetailsService;

    private final PasswordEncoder passwordEncoder;

    private final LoginMapper loginMapper;


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if(authentication == null){
            throw new InternalAuthenticationServiceException("인증 정보가 없습니다");
        }
        String username = authentication.getName();
        if(authentication.getCredentials() == null){
            throw new AuthenticationCredentialsNotFoundException("Credentials이 없습니다");
        }
        String password = authentication.getCredentials().toString();
        try {
            /* 사용자 정보 받기 */
            Member loadedUser = (Member) customUserDetailsService.loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("유저정보가 입력되지않았습니다");
            }
            if (!loadedUser.isAccountNonLocked()) {
                throw new LockedException("잠긴 계정입니다");
            }
            if (!loadedUser.isEnabled()) {
                throw new DisabledException("탈퇴한 계정입니다");
            }
            if (!loadedUser.isAccountNonExpired()) {
                throw new AccountExpiredException("기한 만료 계정입니다");
            }
            /** 실질적인 인증 시작 **/
            if (!passwordEncoder.matches(password, loadedUser.getPassword())) {

                // 아이디/비밀번호 틀린 횟수 증가
                loginMapper.increaseFailCnt(loadedUser.getMbrId());

                // 아이디/비밀번호 틀린 횟수 5회일시
                if("0".equals(loadedUser.getRemainFailCnt()))
                    throw new BadCredentialsException("5회이상 아이디나 비밀번호가 틀렸습니다. <br> 관리자에게 문의하세요");

                // 틀린횟수가 5회가 아닐시
                throw new BadCredentialsException("아이디나 비밀번호가 올바르지 않습니다. <br> 남은 횟수는 " + loadedUser.getRemainFailCnt() + "회입니다");
            }
            if (!loadedUser.isCredentialsNonExpired()) {
                throw new CredentialsExpiredException("인증기한이 만료되었습니다");
            }
            /** 인증 성공 **/
            UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(loadedUser, null, loadedUser.getAuthorities());
            result.setDetails(authentication.getDetails());
            return result;
        }
        catch (NoMemberException e){
            throw new NoMemberException("해당 아이디는 없습니다");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

25번 라인을 보면 UserDetailsServiceloadUserByUsername을 사용하는 것을 확인할 수 있다.
이후 라인을 보면 loadUserByUsername을 통해 받아온 UserDetailsService의 구현체 Member를 검증하여 검증실패일경우 Exception을 던지는 것을 확인할 수 있다.

3. UserDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final LoginRepository loginRepository;
    private final ApiService apiService;
    private final Integer MAX_FAIL = 5;


    public Optional<LoginUser> findById(String loginId){
        return loginRepository.findById(loginId);
    }
    public Optional<UserInfo> findUserInfoByMbrId(String mbrId){
        return loginRepository.findUserInfoByMbrId(mbrId);
    }

    public LoginUserDto findUserByLoginId(String loginId){
        LoginUser loginUser = loginRepository.findUserByLoginId(loginId).orElseThrow(NoMemberException::new);
        return LoginUserDto.from(loginUser);
    }
    /**
     * <h3>사용자가 입력한 ID값에 따라 DB에서 사용자 정보(={@link Member}) 출력</h3>
     * @param loginId the username identifying the user whose data is required.
     * @see CustomAuthenticationProvider#authenticate(Authentication)
     * @throws UsernameNotFoundException
     * @author 김찬영
     */
    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {

        //파라미터 검증
        CustomAssert.isLoginPattern(loginId, "아이디나 비밀번호 패턴이 맞지않습니다");

        LoginUserDto loginUserDto = this.findUserByLoginId(loginId);


        if (loginUserDto.getFailCnt() >= MAX_FAIL)
            throw new LockedException("계정 정지");

        Member member =
                (isNoAuthorizedMember(loginUserDto)) ? creatNoEmailMember(loginUserDto) : creatAuthorizedMember(loginUserDto);

        return member;
    }

    private Boolean isNoAuthorizedMember(LoginUserDto loginUserDto){
        return (!"Y".equals(loginUserDto.getMbrEmailYn()) || StringUtils.isEmpty(loginUserDto.getMbrEmail()));
    }

}

4. UserDetails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
public class Member implements UserDetails {

    private String pkId; //DB PK값

    private String loginId;		// 로그인용 ID 값, emrhssla
    private String password;	// 비밀번호
    private String email;	//이메일
    private Integer failAuth; //실패횟수
    private boolean emailVerified = true;	//이메일 인증 여부
    private String token; //SSO 인증 토큰

    private String nickname;
    private String sexFg;
    private String birthDy;	// 비밀번호
    private boolean locked = true;	//계정 잠김 여부
    private String nickname;	//닉네임

    private Collection<GrantedAuthority> authorities;	//권한 목록


    /**
     * 해당 유저의 권한 목록
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    public void setRole(Collection<? extends GrantedAuthority> authorities){
        this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
    }

    /**
     * 비밀번호
     */
    @Override
    public String getPassword() {
        return password;
    }


    /**
     * PK값
     */
    @Override
    public String getUsername() {
        return nickname;
    }

    public String getSsn() {
        return birthDy.substring(2) + sexFg;
    }

    public String getRemainFailCnt(){return String.valueOf(5-(failAuth+1));}
    public Integer getFailCnt(){return this.failAuth;}
    private void setEmailY(){
        this.emailVerified = true;
    }

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

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

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


    /**
     * 사용자 활성화 여부
     * ture : 활성화
     * false : 비활성화
     * @return
     */
    @Override
    public boolean isEnabled() {
        //이메일이 인증되어 있고 계정이 잠겨있지 않으면 true
        return true;
    }

    public Member(LoginUserDto loginUserDto) {
        //Assert.isTrue(loginUserDto.getLoginId() != null && !"".equals(loginUserDto.getLoginId()) && password != null, "Cannot pass null or empty values to constructor");
        this.loginId = loginUserDto.getLoginId();
        this.nickname = loginUserDto.getUsername();
        this.password = loginUserDto.getPwdNo();
        this.birthDy = loginUserDto.getBirthDy();
        this.sexFg = loginUserDto.getSexFg();
        this.failAuth = loginUserDto.getFailCnt();
        if(isAccountNonLocked())
            this.email = loginUserDto.getMbrEmail();
    }

    /** 인증된 사용자 **/
    public static Member creatAuthorizedMember(LoginUserDto loginUserDto){
        Member member = new Member(loginUserDto);
        member.authorities = setRole("ROLE_USER");
        return member;
    }

    /** 인증되지않은 사용자 **/
    public static Member creatNoEmailMember(LoginUserDto loginUserDto){
        Member member = new Member(loginUserDto);
        member.emailVerified = false;
        member.authorities = setRole("ROLE_NOT_PERMITTED");
        return member;
    }


    /**
     * 권한 설정
     * @param role
     * @return
     */
    private static List<GrantedAuthority> setRole(String role) {
        List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
        authorities.add(new SimpleGrantedAuthority(role));
        return authorities;
    }

}

하나의 유저는 하나의 권한을 갖는 정책으로 세웠다. 때문에 setRole(String)함수처럼 파라미터를 String 하나만 받는다.
만약 사용자가 여러개의 권한을 갖는다는 정책이면 RolesSpring Security - Role (단일 권한, 복합 권한) 를 참조

5. AuthenticationSuccessHandler

로그인 성공시 처리 핸들러

  • Handler말고 successfulAuthentication를 사용하여 처리도 가능하다

successfulAuthentication은 토큰과 같이 간단한 것을 Response에 포함시킬 경우에 사용되고
AuthenticationSuccessHandler은 로그인에 대한 성공, 실패 및 사용자 정의 프로세스가 필요한 경우애 사용한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AuthSuccessHandler implements AuthenticationSuccessHandler {

    private final LoginMapper loginMapper;

    /**
     * 인증 성공 이후
     * @author 김찬영
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Member member = (Member) authentication.getPrincipal();
        log.info("실패 횟수 : {}", member.getFailCnt());

        // 로그인 실패횟수 초기화
        if(member.getFailCnt()!=0)
            loginMapper.updateRestoreFailCnt(member.getMbrId());
    }
}

6. AuthenticationFailureHandler

  • AuthenticationProvider에서 Exception이 터지고 이후의 Handler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Component
@RequiredArgsConstructor
public class AuthFailureHandler implements AuthenticationFailureHandler {

    private final LoginMapper loginMapper;

    /**
     * 인증 실패 경우
     * @author 김찬영
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {

        // 한글 사용
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");

        String errorMsg = "";

        if(exception instanceof AccountExpiredException){
            response.setStatus(HttpServletResponse.SC_CONFLICT);
            errorMsg = "만료된 회원입니다";
        }
        /********** 5회이상 비밀번호 틀렸을시 ************/
        else if(exception instanceof LockedException){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            errorMsg = "계정정지입니다. <br> 관리자에게 문의해주세요";
        }
        else if(exception instanceof DisabledException){
            response.setStatus(HttpServletResponse.SC_CONFLICT);
            errorMsg = "탈퇴한 회원입니다";
        }
        else if(exception instanceof NoMemberException){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            errorMsg = "아이디나 패스워드가 올바르지않습니다.";
        }

        /********** 비밀번호 틀렸을시 ************/
        else if(exception instanceof BadCredentialsException){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            errorMsg = exception.getMessage();
        }
        else if(exception instanceof PatternException){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            errorMsg = "아이디나 패스워드가 올바르지않습니다.";
        }
        response.getWriter().print(errorMsg);
        response.getWriter().flush();S

    }
}
  • response에 errorMsg 를 담아 ajax를 통해 통신요청한 브라우저에게 응답한다
  • 이러한 errorMsglogin.js에서 처리하여 login.html에 보여준다

댓글 쓰기