SSAFY(삼성 청년 SW 아카데미)에서 진행한 프로젝트에서 Spring Security를 사용하여 JWT + OAuth 2.0 방식을 사용하여 SNS 로그인과 토큰 인증을 처리한 경험이 있습니다. 1개의 프로젝트를 약 6~7주 진행했었는데, 시간 내에 Spring Security를 완벽하게 이해하고 넘어가기 힘들었기 때문에 완성된 코드를 약간의 수정을 하는 정도로 사용하였습니다. 그럼에도 SSAFY에서 진행한 프로젝트에서 Spring Security를 사용하여 인증/인가를 처리하였기에 그 경험을 토대로 재직 중인 회사의 서비스의 인증/인가를 Spring Security로 사용하였습니다.
회사에 프론트엔드 개발자가 없었고, 없어도 Vue.js를 사용한 경험이 있기 때문에 풀스택 개발을 진행해도 되지만, 회사의 도메인 특성상 "중복 로그인 방지"라는 요구사항이 필수적으로 구현되어야 했고, 토이 프로젝트에서 Vue.js + Spring Security를 사용했었을 때 중복 로그인 방지를 할 수가 없었습니다.. [뭔가 JWT + Refresh Token을 사용하면 중복 로그인 방지를 할 수 있지 않을까..라는 생각이 들지만 아무튼!] 그래서 Thymeleaf를 사용하는 SSR 방식의 Spring Security Form 인증 방식을 사용해야 했고, 제가 설정한 인증/인가 방식에 대해 정리하고자 이 글을 포스팅 합니다.
Spring Security를 구현하는 방식이나 설정이 매우 다양하고 양 자체도 방대하기 때문에 어떠한 기준이 필요했고 인프런 정수원님의 스프링 시큐리티 강좌를 Base로 하였으며, "교과서로 공부했어요"의 교과서인 Spring Security 공식 Document를 참고하였습니다.
프로젝트 버전
- 개발 도구: IntelliJ Ultimate
- Spring Boot: 2.5.9
- Java 11
- h2 Database
- Thymeleaf
- Spring Security, Spring Web, Spring Data JPA, Lombok
코드를 나열하고 그 이후에 각 클래스 별로 담당하는 역할을 설명을 하고 끝내도록 하겠습니다.
패키지 구조
SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 1.
private final FormAuthenticationSuccessHandler formSuccessHandler;
private final FormAuthenticationFailureHandler formFailureHandler;
// 2.
private final CustomUserDetailsService userDetailsService;
// 3.
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
// 4.
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 5.
.authorizeRequests(a ->
a.anyRequest().authenticated())
// 6.
.formLogin(f -> f
.defaultSuccessUrl("/") // 로그인 성공 시 url
// .loginPage("/login") // Custom Login Page 를 구현 할 경우
// loginPage 주석 시, Spring Security 에서 제공하는 Login 화면 사용
.usernameParameter("email") // form tag에서 input name
.passwordParameter("password") // form tag에서 input name
.failureUrl("/login") // 로그인 실패 시 url
.successHandler(formSuccessHandler) // 로그인 성공 시 handler
.failureHandler(formFailureHandler) // 로그인 실패 시 handler
.permitAll() // login은 인증 되지 않은 사용자도 접근해야 하므로 접근 허용
);
}
// 7.
@Bean
public AuthenticationProvider authenticationProvider() {
return new FormAuthenticationProvider(userDetailsService);
}
}
- Form Login 시 성공과 실패를 처리하기 위한 Handler 의존성 주입
- DB에 저장되어 있는 Member Table의 회원이 이메일과 비밀번호를 입력했을 때 정상적인 회원인지 확인하는 직접 정의한 서비스(이것을 재정의 하지 않을 경우, Spring Security에 내재되어 있는 것을 사용함)
- 인증 방식을 Spring Security에서 제공하는 것 대신에 재정의 한 구현체로 의존성 주입
- resources/static 밑에서 관리하는 통상적인 css, js 관련된 파일을 불러올 때는 별도의 인증이 필요 없음을 설정
- 사용자의 모든 http 요청에 인증이 필요함 걸어둠(인증이 안된 요청이라면 /login으로 감)
- formLogin에 관한 설정 자세한 설명은 주석 참조
- 3번에서 말한 인증 방식을 Bean으로 등록
SecurityConfig 클래스 이외 코드 [스압 주의!]
application.yml
spring:
profiles:
active: local
datasource:
url: jdbc:h2:tcp://localhost/~/h2/atheart
username: sa
password: sa
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 1000
FormAuthenticationFailureHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class FormAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String errorMessage = "";
String email = request.getParameter("email");
request.setAttribute("email", email); // Login 화면에서 사용자가 입력한 Email 유지하기 위함
request.setAttribute("exception", errorMessage);
request.getRequestDispatcher("/login").forward(request, response);
}
}
FormAuthenticationSuccessHandler
@Component
@RequiredArgsConstructor
public class FormAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
setDefaultTargetUrl("/");
// 에러 세션 지우기
clearAuthenticationAttributes(request);
// redirect
redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl());
}
}
CustomUserDetails
@Getter
public class CustomUserDetails extends User {
private final Member member;
public CustomUserDetails(Member member, Collection<? extends GrantedAuthority> authorities) {
super(member.getEmail(), member.getPassword(), authorities);
this.member = member;
}
}
CustomUserDetailsService
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
if (!StringUtils.hasText(email)) {
throw new InsufficientAuthenticationException("이메일은 필수 입력값입니다.");
}
Optional<Member> findMemberOptional = userRepository.findMemberEntityGraphByEmail(email);
if (findMemberOptional.isEmpty()) {
throw new UsernameNotFoundException("존재하지 않는 이메일입니다.");
}
Member findMember = findMemberOptional.get();
Role userRole = findMember.getRole();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(userRole.getRoleCode()));
return new CustomUserDetails(findMember, authorities);
}
}
FormAuthenticationProvider
@RequiredArgsConstructor
public class FormAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String email = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
Member loginMember = userDetails.getMember();
if (!StringUtils.hasText(password)) {
throw new InsufficientAuthenticationException("비밀번호를 입력해주세요.");
} else if (!BCrypt.checkpw(password, userDetails.getPassword())) {
throw new BadCredentialsException("잘못된 비밀번호를 입력하셨습니다.");
}
SessionMember sessionMember = SessionMember.builder() // 세션 저장 객체 [세션에는 필요한 정보만 저장]
.id(loginMember.getId()) // PK
.email(loginMember.getEmail()) // email
.name(loginMember.getName()) // 이름
.build();
return new UsernamePasswordAuthenticationToken(sessionMember, null, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
SessionMember
@Getter
@Setter
@ToString
public class SessionMember implements Serializable {
// Member
private Long id;
private String email;
private String name;
@Builder
public SessionMember(Long id, String email, String name) {
this.id = id;
this.email = email;
this.name = name;
}
}
LoginController
@Controller
public class LoginController {
/**
* Spring Security 에서 제공하는 Login 화면 대신
* Custom 한 Login 화면을 보여주고 싶을 때 사용
*/
@GetMapping("/login")
public String loginPage() {
return "login";
}
}
MemberRepository
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = "role")
@Query("select m from Member m where m.email = :email")
Optional<Member> findMemberEntityGraphByEmail(String email);
}
Member
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id")
private Role role;
@Builder
public Member(String email, String password, String name, Role role) {
this.email = email;
this.password = password;
this.name = name;
this.role = role;
}
}
Role
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Role {
@Id
@Column(name = "role_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String roleCode;
private String roleDesc;
@Builder
public Role(String roleCode, String roleDesc) {
this.roleCode = roleCode;
this.roleDesc = roleDesc;
}
}
InitData
@Profile("local")
@Component
@RequiredArgsConstructor
public class InitData {
private final InitDataService initDataService;
@PostConstruct
public void init() {
initDataService.memberInit();
}
@Component
static class InitDataService {
@PersistenceContext
EntityManager em;
@Transactional
public void memberInit() {
Role role = Role.builder()
.roleCode("ROLE_ADMIN")
.roleDesc("관리자 권한")
.build();
em.persist(role);
Member member = Member.builder()
.email("wangtak@gmail.com")
.password(BCrypt.hashpw("1234", BCrypt.gensalt()))
.name("왕탁이")
.role(role)
.build();
em.persist(member);
}
}
}
이 처럼 코드를 설정한 후에 애플리케이션을 실행하여 localhost:8080에 접속해보면, 다음과 같은 Spring Security에서 제공하는 로그인 화면(localhost:8080/login)으로 이동합니다.
localhost:8080 이후로 다른 URL을 입력해도 로그인 화면(/login)으로 이동하며, 심지어 존재하지 않는 리소스라 할 지라도 먼저 인증을 받도록 합니다. (Filter이기 때문에 Spring 보다 먼저 처리해서 그럼)
InitData 클래스에서 초기 등록한 계정(wangtak@gmail.com/1234)으로 로그인하게 되면 localhost:8080으로 이동한 것을 확인할 수 있습니다.
인증받는 화면, 로그인하는 화면을 Custom으로 바꾸기 위해서는 SecurityConfig 클래스의 6번에 있는 .loginPage("/login")의 주석을 해제하고 LoginController와 같이 /login을 등록하여 직접 만든 Login 화면을 렌더링 하도록 바꾸면 됩니다.
간단하게 DB에 있는 Member 정보를 JPA로 불러와서 인증하는 Spring Security의 Form 로그인 방식을 알아봤습니다. 로그인 기능은 대부분의 서비스의 근본이 되는 것이기 때문에 저 또한 이 글을 기본으로 하여 새로운 기능을 붙여 나아가 보도록 하겠습니다.
'Spring > Spring Security' 카테고리의 다른 글
Spring Security - Nginx LB + 세션 클러스터링(Session Clustering) ③ (0) | 2022.02.21 |
---|---|
Spring Security - Nginx LB + 세션 클러스터링(Session Clustering) ② (0) | 2022.02.17 |
Spring Security - Nginx LB + 세션 클러스터링(Session Clustering) ① (1) | 2022.02.09 |