8-1 스프링 시큐리티 적용하기
스프링 시큐리티는 별도의 프레임워크로 시작되었지만 스프링으로 프로젝트가 통합되었습니다. 약간의 코드와 설정만으로 로그인 처리와 자동 로그인, 로그인 후 페이지 이동 등을 처리할 수 있어 개발의 생산성을 높일 수 있다는 장점이 있습니다.
스프링 시큐리티 관련 설정 추가
application.properties를 이용하는 설정보다 코드를 이용해서 설정을 조정하는 경우가 더 많아 별도의 클래스를 이용해서 설정합니다.
@Log4j2
@Configuration
@RequiredArgsConstructor
public class CustomSecurityConfig {
}
설정 이후 프로젝트를 실행하면 알 수 없는 password가 생성되어 출력됩니다.
스프링 시큐리티는 별도의 설정이 없을 땐 모든 자원에 필요한 권한이나 로그인 여부 등을 확인합니다.
로그인하지 않아도 볼 수 있도록 설정하고 싶다면 개발자가 직접 설정하는 코드가 있어야 합니다.
@Log4j2
@Configuration
@RequiredArgsConstructor
public class CustomSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
log.info("--configure---");
return http.build();
}
}
filterChain()메소드가 동작하면 이전과 달리 바로 접근할 수 있습니다.
filterChain() 메소드 설정으로 모든 사용자가 모든 경로에 접근 가능합니다.
로그 레벨 조정
스프링 시큐리티의 동작은 필터를 통해서 동작하고 많은 수의 필터들이 단계별로 동작하게 됩니다. 문제가 발생하면 어떤 필터에서 어떤 문제가 생겼는지 알 수 있도록 application.properties의 로그 설정을 최대한 낮게 설정하는 것이 좋습니다.
정적 자원의 처리
프로젝트에서 정적으로 동작하는 파일들에는 굳이 시큐리티를 적용할 필요가 없으므로 webSecurityCustomizer() 메소드 설정을 추가합니다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
인증과 인가/권한
- 인증: 흔히 말하는 로그인 개념입니다. 인증을 위해서 사용자는 자신이 알고 있는 자신의 정보를 제공하는데 아이디와 패스워드가 이에 속합니다.
- 인가: 인증이 된 사용자라고 해도 이에 접근할 수 있는 권한이 있는지를 확인하는 과정을 의미합니다.
인증과 username
스프링 시큐리티에서 로그인에 해당하는 인증 단계는 과거의 웹과 다르게 동작합니다. 다음과 같은 단계를 거쳐 동작합니다.
- 사용자의 아이디만으로 사용자의 정보를 로딩
- 로딩된 사용자의 정보를 이용해서 패스워드를 검증
동작 방식은 웹에서 로그인 처리로 아이디와 패스워드를 한번에 조회하는 방식과 달리 아이디만을 이용해 사용자 정보를 로딩하고 나중에 패스워드를 검증하는 방식입니다.
인증 처리는 인증제공자라는 존재를 이용해 처리하는데 인증제공자와 그 이하의 흐름은 일반적으로 커스터마이징 해야 하는 경우가 거의 없어 실제 인증 처리를 담당하는 객체만을 커스터마이징 하는 경우가 대부분입니다.
인증 처리를 위한 UserDetailService
가장 중요한 부분은 실제로 인증을 처리하는 UserDetailService라는 인터페이스의 구현체입니다. loadUserByUsername()이라는 하나의 메소드를 가지는데 이것이 인증을 처리할 때 호출되는 부분입니다.
@Log4j2
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("loadUserByUsername");
return null;
}
}
UserDetails라는 반환 타입
loadUserByUsername()의 반환 타입은 UserDetails라는 인터페이스 타입으로 지정되어 있습니다. UserDetails는 사용자 인증과 관련된 정보들을 저장하는 역할을 합니다. 스프링 시큐리티는 내부적으로 UserDetails 타입의 객체를 이용해서 패스워드를 검사하고 사용자 권한을 확인하는 방식으로 동작합니다.
PasswordEncoder
스프링 시큐리티는 기본적으로 PasswordEncoder라는 존재를 필요로 합니다. PasswordEncdoer 역시 인터페이스로 제공하는데 이를 구현하거나 스프링 시큐리티 API에서 제공하는 클래스를 지정할 수 있습니다. 여러 PasswordEncoder 타입의 클래스 중에서 가장 무난한 것은 BCryptPasswordEncoder라는 클래스입니다. 해시 알고리즘으로 암호화 처리되는데 같은 문자열이어도 매번 해시 처리된 결과가 달라 암호화에 많이 사용됩니다.
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
private PasswordEncoder passwordEncoder;
public CustomUserDetailService() {
this.passwordEncoder = new BCryptPasswordEncoder();
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
log.info("loadUserByUsername"+username);
UserDetails userDetails = User.builder().username("user1")
.password(passwordEncoder.encode("1111"))
.authorities("ROLE_USER")
.build();
return userDetails;
}
어노테이션을 이용한 권한 체크
특정 경로에 접근할 수 있는 권한을 설정하는 작업은 코드 혹은 어노테이션을 이용해서 지정할 수 있습니다. 코드로 설정 시 매번 컨트롤러의 메소드를 작성한 후 다시 조정해야 하는 불편함이 있습니다.
@EnableGlobalMethodSecurity
어노테이션으로 권한을 설정하려면 설정 관련 클래스에 @EnableGlobalMethodSecurity 어노테이션을 추가해야 합니다.
@EnableGloablMethodSecurity의 prePostEnabled 속성은 원하는 곳에 @PreAuthorize 혹은 @PostAuthorize 어노테이션을 이용해서 사전 혹은 사후의 권한을 체크할 수 있습니다.
@PreAuthorize("hasRole('USER')")
@GetMapping("/register")
public void registerGET(){
}
registerGET()에는 @PreAuthorize 어노테이션을 적용합니다. 표현식을 이용해서 특정한 권한을 가진 사용자만 접근 가능하도록 지정합니다.
@PreAuthorize/@PostAuthorize 접근 제한 표현식
()안에 들어가는 문자열은 예전에 별도의 메소드로 지정하는 내용을 표현식으로 사용하게 된 형태입니다.
표현식(메소드) | 설명 |
authenticated() | 인증된 사용자들만 허용 |
permitAll() | 모두 허용 |
anonymous() | 익명의 사용자 허용 |
hasRole(표현식) | 특정한 권한이 있는 사용자 허용 |
hasAnyRole(표현식) | 여러 권한 중 하나만 존재해도 허용 |
커스텀 로그인 페이지
별도의 페이지를 생성하지 않아도 자동으로 로그인 페이지를 제공하지만 화면의 디자인은 반영할 수 없어 별도의 로그인 페이지를 만들어 사용하는 것이 더 일반적입니다.
http.formLogin().loginPage("/member/login");
return http.build();;
loginPage()를 지정하면 로그인이 필요한 경우에 '/member/login' 경로로 자동 리다이렉트 됩니다. 브라우저를 종료하고 재시작해서 '/board/register'를 호출하면 경로를 찾을 수 없다는 화면을 보게됩니다.
@Controller
@RequestMapping("/member")
@Log4j2
@RequiredArgsConstructor
public class MemberController {
@GetMapping("/login")
public void loginGET(String errorm String logout){
log.info("login get....");
log.info("logout: "+logout);
}
}
loginGET()에는 error와 logout이라는 이름의 파라미터를 저장하는데 나중에 로그인 과정에 문제가 생기거나 로그아웃 처리할 때 사용하기 위해서 입니다.
로그아웃 처리 설정
HttpSession을 이용해서 처리되기 때문에 로그앗운 세션을 유지하는데 사용하는 쿠키를 삭제하면 자동으로 로그아웃이 됩니다. 스프링 시큐리티는 기본적으로 '/logout'이라는 경로를 제공하는데 CSRF 토큰이 비활성화 되는 경우에는 GET방식으로도 로그아웃이 가능합니다.
remember-me 기능 설정
스프링 시큐리티의 remember-mem기능은 쿠키를 이용해서 브라우저에 로그인 했던 정보를 유지하기 때문에 매번 로그인을 실행할 필요가 없어집니다. 기존의 유지 방법이 HttpSession을 이용했던 것과 달리 remember-me는 쿠키에 유효 기간을 지정해서 쿠키를 브라우저가 보관하게 하고 쿠키의 값인 특정 문자열을 보관시켜서 로그인 관련 정보를 유지하는 방식입니다.
스프링 시큐리티는 설정을 변경하는 것만으로 사용이 가능한데 커스텀 로그인 페이지를 만드는 경우에는 약간의 추가 설정이 필요합니다.
테이블 생성
remember-me의 쿠키값을 생성할 때 필요한 정보들을 보관하기 위한 무난한 방식은 데이터베이스를 이용하는 것입니다.
create table persistnat_logins(
username varchar(64) not null,
series varchar(64) primary key,
token varchar(64) not null,
last_used timestamp not null
);
8-2 회원 데이터 처리
스프링 시큐리티에서 실제 사용자의 정보 로딩은 UserDetailsService를 이용해서 처리됩니다.
회원 데이터는 가능한 여러 권한을 가지도록 구성하는 것이 좋기 때문에 별도의 엔티티를 구성하는 방식 대신 하나의 엔티티 객체에 여러 값을 표현할 수 있는 @ElementCollection을 이용하도록 구성하고 enum 타입을 사용해 보도록 합니다.
회원 도메인과 Repository
회원 데이터는 다음과 같은 속성들로 구성합니다.
- 회원 아이디(mid)
- 패스워드(mpw)
- 이메일(email)
- 탈퇴여부(del)
- 등록일/수정일(reDate, modDate)
- 소셜 로그인 자동 회원 가입 여부(social)
MemberRepository와 테스트 코드
repository 패키지에는 MemberRepository 인터페이스를 선언하고 로그인 시에 MemberRole을 같이 로딩할 수 있도록 메소드를 추가합니다.
public interface MemberRepository extends JpaRepository<Member, String>{
@EntityGraph(attributePaths = "roleSet")
@Query("select m from Member m where m.mid= :mid and m.social=false")
Optional<Member> getWithRoles(String mid);
}
회원 조회 테스트
@SpringBootTest
@Log4j2
public class MemberRepositoryTests{
@Autowired
private MemberRepository memberRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Test
public void insertMembers(){
IntStream.rangeClosed(1,100).forEach(i-> {
Member member = Member.build()
.mid("member+i")
.mpw(passwordEncoder.encode("1111"))
.email("email"+i+@aaa.bbb)
.build();
member.addRole(MemberRole.USER);
if(i>=90){
member.addRole(MemberRole.ADMIN);
}
memberRepository.save(member);
});
}
}
8-3 소셜 로그인 처리
대부분의 소셜 로그인은 OAuth2라는 방식을 이용해 데이터를 주고 받아 사용자의 정보를 전달하는 방식으로 처리됩니다.
소셜 로그인이 처리되는 과정 OAuth2
OAuth2는 문자열로 구성된 '토큰(token)'을 주고받는 방식으로 토큰을 발행하거나 검사하는 방식을 통해 서비스 간 데이터를 교환합니다.
CustomSecutiryConfig 설정 변경
스프링 부트의 OAuth2 Client를 이용할 때는 설정 관련 코드에 OAuth2 로그인을 사용한다는 설정을 추가해야 합니다.
http.oauth2Login().loginPage("/member/login");
<div>
<a href="/oauth2/authorization/kakao">KAKAO</a>
</div>
login.html에는 카카오 로그인을 위한 링크를 처리합니다. 이 링크는 OAuth2 Client 라이브러리를 통해서 자동으로 생성되는 링크이므로 다른 값을 사용하지 않도록 주의합니다.
로그인 연동 후 이메일 구하기
로그인된 후에 전달하는 정보가 UserDetails 타입이 아니기 때문에 문제가 발생합니다. 이를 처리하려면 UserDetailService 인터페이스를 구현하듯이 OAuth2UserService 인터페이스를 구현해야 합니다.
OAuth2UserService 인터페이스는 그 자체를 구현할 수도 있겠지만 하위 클래스인 DefaultOAuth2UserService를 상속해서 구현하는 방식이 가장 간단합니다.
@Log4j2
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService extends DefaultOAuth2UserService{
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
return super.loadUser(userRequest);
}
}
loadUser()에는 카카오 서비스와 연동된 결과를 OAuth2UserRequest로 처리하기 때문에 이를 이용해서 원하는 정보를 추출해야 합니다.
소셜 로그인 후처리
소셜 로그인에 사용한 이메일이 존재하는 경우와 그렇지 않은 경우에 어떻게 처리할 것인지 결정이 필요합니다. 해당 이메일을 가진 사용자가 없을 때는 어떻게 처리해야 하는지가 문제입니다. 새로운 회원으로 간주하고 Member 도메인 객체를 직접 생성해서 저장한 후에 MemberSecurityDTO를 생성해서 반환합니다.
AuthenticationSuccessHandler를 이용한 후처리
스프링 시큐리티는 로그인 성공과 실패를 커스터마이징할 수 있도록 AuthenticationSuccessHandler와 AuthenticationFaileHandler 인터페이스를 제공합니다.
'자바웹개발 워크북' 카테고리의 다른 글
자바웹개발 워크북(7) (0) | 2025.01.20 |
---|---|
자바웹개발 워크북(6) (1) | 2025.01.20 |
자바웹개발 워크북(5) (0) | 2025.01.16 |
자바웹개발 워크북(4) (0) | 2025.01.05 |
자바웹개발 워크북(3) (0) | 2025.01.02 |