본문 바로가기

스프링

[Security] Authentication(Security Form Login 구현)

이번 포스트는 저번에 다뤘던 Security Form Login에 대해서 직접 구현해보려고 한다.

현재, API 방식이 많이이뤄지고있지만 Form 로그인 방식도 충분히 많이 이뤄지고 있어 해보려고한다.

이 예제는 어떤 회원에 대한 로그인을 기준으로 진행해보려고 한다.

이 회원은 아이디, 비밀번호, 권한 총 세 가지를 가지고 있으며 회원가입 할 때 설정할 수 있다.

권한은 일반(USER), 관리자(ADMIN)으로 이루어져 있으며 관리자만 들어갈 수 있는 URL, 관리자와 일반 회원 모두 들어갈 수 있는 URL이 존재한다.

  • /register : 회원가입
  • /login : 로그인
  • /logout : 로그아웃
  • /main : 메인 페이지(로그인 후 이용가능)
  • /user : 일반(USER) 회원, 관리자(ADMIN) 사용 가능
  • /admin : 관리자(ADMIN) 사용 가능

이를 실제로 구현해보자.

Entity

Member 엔티티는 식별자, 아이디, 비밀번호, 권한을 가진다.

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SequenceGenerator(
        name = "MEMBER_GENERATOR",
        sequenceName = "MEMBER_SEQ"
)
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_id")
    private Long id;
    private String username;
    private String password;
    @Enumerated(EnumType.STRING)
    private RoleType roleType;

    @Builder
    public Member(String username, String password, RoleType roleType) {
        this.username = username;
        this.password = password;
        this.roleType = roleType;
    }
}

여기서, RoleType은 권한으로 Enum 타입으로 다음과 같이 설정했다.

@Getter
public enum RoleType {
    ROLE_USER("ROLE_USER", "일반"), ROLE_ADMIN("ROLE_ADMIN", "관리자");

    String auth;
    String desc;

    RoleType(String auth, String desc) {
        this.auth = auth;
        this.desc = desc;
    }
}

Repository

Repository는 Spring Data JPA를 활용하여 간단하게 구현하였다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByUsername(String username);
}

Service

이 예제에서는 간단하게 Security를 활용한 로그인 로직만 진행할 것이기 때문에 단순히 회원을 가입할 때 저장하는 메소드를 구성한다.

보다 객체지향적인 설계를 위해 인터페이스를 만들고 구현체를 만들었다.

public interface MemberService {
    Member save(MemberDto memberDto);
}
@Service
@Transactional
public class MemberServiceImpl implements MemberService {

    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private MemberRepository memberRepository;

    @Override
    @Transactional
    public Member save(MemberDto memberDto) {
        return memberRepository.save(
                Member.builder()
                        .username(memberDto.username)
                        .password(passwordEncoder.encode(memberDto.getPassword()))
                        .roleType(memberDto.getRoleType())
                        .build());
    }
}

여기서, PasswordEncoder는 데이터베이스에 저장되기전에 인코딩하여 보안성을 증진시킨다.

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

인코더는 다음과 같이 빈으로 등록하여 사용하고 PasswordEncoderFactories를 통해 인코더 하나를 만들어 사용한다.

bcrypt 방식을 디폴트로 인코딩한다.

Security Configuration

Spring Security를 사용하기 위해 @EnableWebSecutiry 어노테이션을 활용하여 Security를 등록해준다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final FormAuthenticationSuccessHandler formAuthenticationSuccessHandler;
    private final FormAuthenticationFailureHandler formAuthenticationFailureHandler;

    // 패스워드 인코더
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // Custom AuthenticationProvider 빈 설정
    @Bean
    public AuthenticationProvider authenticationProvider() {
        return new FormAuthenticationProvider();
    }

    // 정적 Resource는 Security에서 제외
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                .antMatchers("/favicon.ico", "/resources/**", "/error");
    }

    // Custom AuthenticationProvider 등록
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

    // HttpSecurity 설정
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/login", "/register", "/logout").permitAll()
                .mvcMatchers("/user").hasAnyRole("USER", "ADMIN")
                .mvcMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/login_proc")
                .defaultSuccessUrl("/main")
                .successHandler(formAuthenticationSuccessHandler)
                .failureHandler(formAuthenticationFailureHandler)
                .permitAll();
    }
}

위의 코드에서는 다음과 같은 설정을 진행한다.

우리는 실제로 정적 소스들에 대해서 Security 검증을 진행할 필요가 없으므로 이를 제외할 필요가 있다.

만약, 아니라면 모든 정적 파일들을 불러오는데 검증을 거쳐야 하기 때문에 제대로 로드를 못할 수도 있다.

그리고 위에서 PasswordEncoder를 사용하기 위해서 빈으로 등록하는 것을 확인할 수 있다.

우리는 처음에 말했던 요구사항에 대해 설정해보자.

로그인을 하고 회원가입을 하는 부분에 대해서는 누구나 접근이 가능해야한다.

만약 아니라면, 처음부터 등록된 아이디가 아니라면 어느 누구도 홈페이지를 이용할 수 없을 것이다.

로그아웃은 로그인을 하지 않은 대상에 대해서도 로그아웃을 해도 무방하므로 일단은 누구나 접근이 가능하도록 하였다.

/user에 대해서는 로그인을 진행한 모든 권한(일반, 관리자)에 대해서 접근이 가능하도록 설정하였다.

/admin에 대해서는 로그인을 진행한 관리자에 대해서 접근이 가능하도록 설정하였다.

지금은 계층 관계를 명확히 하지 않았기에 추후에 이름이 변경되거나 다른 권한을 추가로 가능하게하고 싶다면 수정해야한다.

그리고 나머지에 대해서는 모두 인증을 해야 사용할 수 있게 설정하였다.

또한, 우리는 Form 로그인을 사용한다고 하였다.

Spring Security에서는 자체적으로 Form 로그인을 제공해주는데 우리의 Custom한 로그인 페이지를 사용하려면 위와 같이 loginPage에 설정을 해주면 된다.

그리고 실제로 Form Action을 loginProcessingUrl에 등록해주면된다.

우리가 로그인이 성공한 후 이동할 기본 페이지를 설정할 수 있다.

그리고 로그인이 성공하거나 실패할 때 동작할 메소드를 실행하기 위한 핸들러를 등록할 수 있다.

이 부분에 대해서는 추후에 알아보도록 하겠다.

우리는 위에서 이전 Security에서 알아본 것처럼 실제로 로그인을 처리하는 것은 AuthenticationProvider이다.

우리는 Custom하게 AuthenticationProvider을 생성하고 이를 등록하여 우리의 로그인을 처리하도록 만들 것이다.

만들어진 Provider은 위에서 처럼 단순하게 등록해주면 된다.

이제 실제로 살펴보자.

UserDetails, UserDetailsService

우리는 Form에서 입력한 아이디와 일치하는 회원의 정보가 존재한다면 꺼내와서 실제로 입력한 정보와 데이터베이스의 정보가 일치하는지 확인한다.

이를 위해 UserDetailsService에서 데이터베이스에서 정보를 꺼내오는 메소드인 loadUserByUsername를 구현하고 UserDetails를 리턴한다. 만약, 일치하지 않는다면 Exception을 발생할 것이다.

일치하게되어 리턴된 UserDetails는 우리가 만든 Provider에서 인증 객체(UsernamePasswordAuthenticationToken)을 구성하여 AuthenticationManager에 전달하고 이를 기반으로 SecurityContext를 만들고 SecurityContextHolder에 저장하고 세션에 저장하는 등의 절차를 밟게 된다.

과정을 살펴보았으니 이제 실제로 Custom하게 구성한 UserDetails, UserDetailsService에 대해 살펴보자.

public class MemberDetail implements UserDetails {

    private String username;
    private String password;
    private List<GrantedAuthority> authorities;

    @Builder
    public MemberDetail(String username, String password, List<GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

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

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

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

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

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

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

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

UserDetails를 구현한 MemberDetail은 아이디를 뜻하는 username과 비밀번호를 뜻하는 password와 권한을 뜻하는 authorities를 가지게 구성하였으며 메소드들은 오버라이드하여 해당 메소드에 맞게 구성하였다.

@Service
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("아이디가 존재하지 않습니다."));
        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(member.getRoleType().toString()));

        return MemberDetail.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .authorities(roles)
                .build();
    }
}

UserDetailsService를 구현한 MemberDetailService는 memberRepository를 주입받아 입력받은 아이디에 대한 정보가 데이터베이스에 있는지 확인한다.

만약, 존재하지 않는다면 UsernameNotFountException을 발생시킨다.

만약 존재한다면, 역할을 따로 구성한 후 빌더 패턴을 활용하여 MemberDetail을 구성하여 반환한다.

FormAuthenticationProvider

우리는 실제로 인증을 처리하기 위해서는 Provider가 필요하고 이를 Custom하게 구성해보았다.

다음에서는 비밀번호가 일치하는지에 대해서 확인을 해야 한다.

위에서 언급했듯이, 데이터베이스에 저장하는데 있어 보안성을 증진하기 위해 인코딩하여 저장하였기 때문에 비밀번호를 단순 equals로 비교할 순 없다.

그래서 인코딩할 때 사용하였던 PasswordEncoder을 그대로 활용하여 match 메소드를 활용하여 비교하면 된다.

만약, 일치하지 않는다면 BadCredentialsException을 발생시킨다.

아니라면, 인증 객체(UsernamePasswordAuthenticationToken)에 권한을 추가하여 생성하게 된다.(인증이 완료되었다.)

@Component
public class FormAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        MemberDetail memberDetail = (MemberDetail) userDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password, memberDetail.getPassword())) {
            throw new BadCredentialsException("비밀번호가 틀립니다.");
        }

        return new UsernamePasswordAuthenticationToken(username,password, memberDetail.getAuthorities());
    }

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

FormAuthenticationSuccessHandler

클라이언트는 보통 어떤 페이지를 들어갈 때, 로그인이 필요한 url을 입력하고 해당 접근이 막혀 로그인하는 사이트로 가게 된다.

클라이언트는 로그인이 된 후 원래 접속한 사이트로 Redirect되길 원할 것이다. 그것이 훨씬 사용성이 좋을 것이다.

이를 위해 로그인이 성공한다면 HttpSessionRequestCache에 저장되어 있던 주소를 활용해 Redirect 해보려고 한다.

만약, 아니라면 단순 기본 주소인 /main으로 가려고 한다.

다음과 같이 SimpleUrlAuthenticationSuccessHandler를 상속받아 이를 구현한다.

메소드를 오버라이드하여 해당 Request와 Response에 대한 정보를 받아 HttpSessionRequestCache로 구성하고 해당 주소로 Redirect할 수 있도록 RedirectStrategy를 활용하여 Redirect한다.

@Component
public class FormAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        setDefaultTargetUrl("/main");

        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String redirectUrl = savedRequest.getRedirectUrl();
            redirectStrategy.sendRedirect(request, response, redirectUrl);
        } else {
            redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl());
        }
    }
}

FormAuthenticationFailureHandler

클라이언트가 로그인에 실패하였을 때, 실행되는 메소드이다.

단순하게 ErrorMsg를 설정하여 해당 부분을 파라미터로 보내주는 방식으로 메소드를 구현하였다.

@Component
public class FormAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String errorMsg = "Invalid";

        setDefaultFailureUrl("/login?error=true&exception="+errorMsg);
        super.onAuthenticationFailure(request, response, exception);
    }
}

Controller

이제 실제로 동작하기 위한 컨트롤러를 살펴보자.

간단하게 회원가입, 로그인, 메인페이지, 유저 페이지, 관리자 페이지에 대해 구성하였다.

로그아웃을 보면 SecurityContextLogoutHandler를 확인할 수 있는데, 이 부분에 대해서는 세션에 저장되어 있는 SecurityContext를 제거함으로써 더이상 새로운 접근에서 인증받은 상태가 아니도록 만들었다.

나머지는 간단한 부분이라 설명을 생략하겠다.

@Controller
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    // 회원가입 페이지 접근
    @GetMapping("/register")
    public String getRegisterForm(MemberDto memberDto) {
        return "register";
    }

    // 회원가입 진행
    @PostMapping("/register")
    public String register(MemberDto memberDto) {
        log.info("member register : {}", memberDto.getPassword());
        memberService.save(memberDto);
        return "redirect:/login";
    }

    // 로그인 페이지 접근
    @GetMapping("/login")
    public String getLoginForm(MemberDto memberDto) {
        return "login";
    }

    // 메인 페이지 접근
    @GetMapping("/main")
    public String main(MemberDto memberDto, Model model) {
        String name = SecurityContextHolder.getContext().getAuthentication().getName();
        model.addAttribute("name", name);
        return "main";
    }

    // 로그아웃 진행
    @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null) {
            new SecurityContextLogoutHandler().logout(request, response, auth);
        }

        return "redirect:/login";
    }

    // 유저 페이지 접근
    @GetMapping("/user")
    public String user(Model model) {
        String name = SecurityContextHolder.getContext().getAuthentication().getName();
        model.addAttribute("name", name);
        return "user";
    }

    // 관리자 페이지 접근
    @GetMapping("/admin")
    public String admin(Model model) {
        String name = SecurityContextHolder.getContext().getAuthentication().getName();
        model.addAttribute("name", name);
        return "admin";
    }
}

결과

유저

1. 일반 유저로 회원가입

2. 일반 유저로 로그인

3. 일반 유저로 관리자 페이지 접근

  • 실패

4. 일반 유저로 일반 페이지 접근

  • 성공

관리자

1. 관리자 유저로 회원가입

2. 관리자 유저로 로그인

3. 관리자 유저로 관리자 페이지 접근

  • 성공

4. 관리자 유저로 일반 페이지 접근

  • 성공