본문 바로가기
  • A space that records me :)
기술/Spring Security

[Spring security] Authentication 라이브러리 구현

by yjkim_97 2021. 10. 23.

보통 웹 서비스에서는 로그인과 권한인증 등의 기능은 필수이며 구현해야하는 기본중의 기본적인 서비스이다.
물론 내부에서만 사용하는 서비스인 경우 필요 없는 기능인 경우가 존재하지만, 대부분 시스템에서 필요한 기능이며, 개발을 할 때마다 새로 구현하고,, 여기저기서 구현했던 로직을 짜집기하고,, 이런 방식으로 진행해 왔다.

각 프로젝트마다 같은 기능을 구현하는 것인 데 이것을 사내 모듈로 만들어 사용하면 조금 더 개발시간을 단축하고, 파생되는 버그를 줄일 수 있지 않을까?라는 의견이 나와 사내 Authentication 라이브러를 내가 직접 구현하게 되었다.

Authentication 라이브러리는 인증 및 권한 관련 기능을 제공하며, 각 프로젝트마다 원하는 기능을 세팅할 수 있도록 구현했다.

사내 라이브러리를 구현한 것 처럼 나만의 개인 라이브러리를 만들어 사용하는 것도 아주 좋을 것 같다!!

 


Authentication 라이브러리에 대한 자세한 설명을 아래의 글을 참고...

개발환경

  • STS 4
  • Java 8
  • Mariadb

사용 라이브러리

  • spring-security
  • JPA, QueryDsl
  • Mybatis

원래 나는 투입될 인력이 아니였으며 신입 개발자 한 분과 TM역할의 팀장님 두분으로 구성되었지만, 단기간안에 성과를 내기 위해, 전에 권한 인증 프로세스를 구현해 본 나를 추가로 투입하였다고 하셨다.

내가 참여하여 만들어진 라이브러리를 사내에서 사용될 생각을 하니 설레고 기분이 좋으면서, 살짝 부담도 있었다. 기대하시는 만큼 단기간안에 성과를 내리라 다짐하였지만, 나의 스케줄 관리 미스가 조금 있어, 예상했던 기한을 조금 넘겼다..
스케줄관리는 어떻게 해야하고, 또 어떻게 해야 스케줄에 딱딱 맞게 진행할 수 있을까?...


Authentication 라이브러을 구현하면서 사용한 Spring-security에 초점을 맞춰서 기술할 것이므로, 다른 기능에 대해서는 생략한다.

 

스프링 시큐리티 중 커스텀된 항목은 이텔릭체로, 이중 내가 직접 커스텀한 항목은 이텔릭체에 밑줄을 쳤다. (개발 인력은 신입 개발자분과 나 총 2명이였다.)

1. Dependency, Plugin 추가

spring boot web, security, mybatis, jpa, querydsl을 추가한다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- spring-security -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- mybatis -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
</dependency>

<!-- JPA -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- qureydsl -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <scope>provided</scope>
</dependency>

<!-- JPA plugin -->
<plugin>
  <groupId>com.mysema.maven</groupId>
  <artifactId>apt-maven-plugin</artifactId>
  <version>1.1.3</version>
  <executions>
    <execution>
      <goals>
        <goal>process</goal>
      </goals>
      <configuration>
        <outputDirectory>target/generated-sources/java</outputDirectory>
        <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
      </configuration>
    </execution>
  </executions>
</plugin>

 

2. properties 작성

Authentication 라이브러 구현을 위한 프로퍼티를 만든다.
(차후 이 라이브러리를 사용할 때, 프로퍼티 값에 따라 기능을 제공한다.)
프로퍼티명 : authentication-config.yml

security: 

   ### 중복 로그인
   session:
      # 중복 로그인 체크 여부
      use: true # true
      # 중복 허용 개수 (n개 중복 가능) 
      max-count: 1
   
   ### 자동로그인
   rememberme:
      # 기능 사용 여부
      use: true # true
      # 자동로그인 유효기간 (n초 이후 만료)
      expire:
         seconds: 2629800 # 2629800 (1달)
   
   ### 비밀번호 만료
   credentials-expired:
      # 비밀번호 만료 체크 여부
      use : true # true
      # 비밀번호 만료시 리다이렉트 uri 
      redirect: /credentialsExpired
      # 비밀번호 유효기간 (월,일) - month,date 동시 사용 가능 
      month:
      date: 35 
   
   ### 회원가입
   singup:
      uri : /signup
      process:
         uri : /signupProcess
   
   ### 탈퇴
   withdraw:
      keep-loginId: false
      
   ### 로그인
   login:
      uri: /login
      # jsp form 파라미터
      param: 
         id: userId
         pwd: userPwd
      # jsp form action uri
      process:
         uri: /loginProcess
      success: 
         redirect-uri: /
      fail:
         # 비밀번호 연속실패 잠김 기능
         imsi-lock:
            # 사용 여부    
            use: true  # true
            recount:
               use: false # false
               max-minute: 1
            max-count: 5
            # 비밀번호 연속 실패시 계정이 잠기는 시간
            lock-minute: 30 # 30
         redirect-uri:
            default: ${security.login.uri}?error=true
            notApprove:                                                               # 승인 미완료      
            inactive:                                                                 # 휴면
            userNotFound: /loginfail/userNotFound                                     # 해당 정보 없음
            badCredentials:                                                           # 비밀번호 오류
            disabled:                                                                 # 비활성화
            disabledBlackList:                                                        # 비활성화(블랙리스트) 
            locked:                                                                   # 잠김
            lockedByPassword:                                                         # 잠김(비밀번호 연속실패)
            credentialsExpired:                                                       # 비밀번호 만료
            serviceExpired:                                                           # 서비스만료
            sessionAuthentication:                                                    # 세션 중복 에러
            runtimeAuthentication:                                                    # 인증에러
               
   ### 로그아웃
   logout:
      uri: /logout
   
   ### 권한허용 uri 리스트
   permitAll: >
      ${security.login.uri}
      ,${security.login.process.uri}
      ,${security.singup.uri}
      ,${security.singup.process.uri}
   
   ### security 미적용 uri 리스트
   ignoring: >
      /favicon.ico
      ,/js/**
      ,/ignore/**
      ,/api/**
      ,/api
      ,${security.login.fail.redirect-uri.userNotFound}
#,${security.login.fail.redirect-uri.notApprove}
#,${security.login.fail.redirect-uri.inactive}
#,${security.login.fail.redirect-uri.badCredentials}
#,${security.login.fail.redirect-uri.disabled}
#,${security.login.fail.redirect-uri.disabledBlackList}
#,${security.login.fail.redirect-uri.locked}
#,${security.login.fail.redirect-uri.lockedByPassword}
#,${security.login.fail.redirect-uri.credentialsExpired}
#,${security.login.fail.redirect-uri.serviceExpired}
#,${security.login.fail.redirect-uri.runtimeAuthentication}}

 

3. Application 설정

properties 파일을 두개 이상 읽기 위한 설정이 들어가있다.

package com.innerwave.surfinn;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.ConfigurableApplicationContext;
import com.innerwave.surfinn.business.common.AuthCacheManager;

@SpringBootApplication
@EnableCaching
public class SpringSecurityApplication {
	private static final String PROPERTIES = "spring.config.location="
	        +"classpath:/config/application.yml"
	        +",classpath:/authentication-config.yml"
	        ;
	
	public static void main(String[] args) {
		
		ConfigurableApplicationContext context = new SpringApplicationBuilder(SpringSecurityApplication.class)
				.properties(PROPERTIES)
				.run(args);
		
		context.getBean(AuthCacheManager.class).setCacheInitial();
	}

}

 

4. SecurityConfig.java

/**
 * SecurityConfig
 *
 * 스프링 시큐리티 보안,인증 설정
 *
 */

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	private final CustomUserDetailsService customUserDetailsService;	
	private final PsstLoginRepository psstLoginRepository;	
	
	@Qualifier("dataSource")
	private final DataSource dataSource;	
	
	@Value("${security.rememberme.expire.seconds}")
	private int remembermeExpireSeconds;
	
	@Value("${security.permitAll}")
	private String[] securityPermitallArray;
	
	@Value("${security.ignoring}")
	private String[] securityIgnoringArray;
	
	@Value("${security.login.success.redirect-uri}")
	private String securityLoginSuccessRedirect;
	
	@Value("${security.login.uri}")
	private String securityLoginUri;
	
	@Value("${security.login.process.uri}")
	private String securityLoginProcessUri;
	
	@Value("${security.logout.uri}")
	private String securityLogoutUri;
	
	@Value("${security.login.param.id}")
	private String loginParamId;
	
	@Value("${security.login.param.pwd}")
	private String loginParamPwd;
	
	@Value("${security.rememberme.use}")
	private boolean remembermeUse;
	
	@Value("${security.session.use}")
	private boolean sessionUse;
	
	@Value("${security.session.max-count}")
	private Integer sessionMaxCount;
	
	@Bean
	public HttpFirewall defaulthttpFirewall() {
		return new DefaultHttpFirewall();
	}
	
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	@Bean
	public HttpAccessDeniedHandler accessDeniedHadler() 
	{
		return new HttpAccessDeniedHandler();
	}
	
	@Bean
	public HttpAuthenticationEntryPoint authenticationEntryPoint()
	{
		return new HttpAuthenticationEntryPoint();
	}
	
	@Bean
	public AuthenticationFailureHandler loginFailureHandler()
	{
		return new LoginFailureHandler();
	}
	
	@Bean
	public AuthenticationSuccessHandler loginSuccessHandler()
	{
		return new LoginSuccessHandler(securityLoginSuccessRedirect);
	}
	
	@Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }
	
	@Bean
	public UserDetailsChecker preUserDetailsChecker()
	{
		return new PreAccountStatusUserDetailsChecker();
	}
	
	@Bean
	public UserDetailsChecker postUserDetailsChecker()
	{
		return new PostAccountStatusUserDetailsChecker();
	}
	
	@Bean
	public DaoAuthenticationProvider authenticationProvider()
	{
		CustomAuthenticationProvider provider = new CustomAuthenticationProvider();
		provider.setUserDetailsService(customUserDetailsService);
		provider.setPasswordEncoder(passwordEncoder());
		provider.setPreAuthenticationChecks(preUserDetailsChecker());
		provider.setPostAuthenticationChecks(postUserDetailsChecker());
		return provider;
	}


	/** 
	 * 자동로그인 서비스 설정	
	 * 
	 * {@link AuthConstants}.REMEMBER_ME_KEY : RemeberMeAuthenticationToken 생성시 KeyHash 인코딩에 사용
	 * {@link CustomUserDetailsService} : 커스텀한 UserDetails 객체
	 * {@link CustomPersistentTokenRepository}  : 커스텀한 PersistentTokenRepository 객체
	 * {@link AuthConstants}.# : Remember Me 토큰이 담긴 Cookie Key 명
	 * {@link AuthConstants}.REMEMBER_ME_PARAMETER : 클라이언트단에서 자동로그인 checkbox name
	 * tokenValiditySeconds : 토큰 유효기간 설정
	 * 	
	 * 
	 * @return {@link RememberMeServices}
	 */
	@Bean
	public RememberMeServices customRememberMeServices() {		
		CustomPersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices = new CustomPersistentTokenBasedRememberMeServices(AuthConstants.REMEMBER_ME_KEY, customUserDetailsService, psstLoginRepository); 
		persistentTokenBasedRememberMeServices.setParameter(AuthConstants.REMEMBER_ME_PARAMETER);
		persistentTokenBasedRememberMeServices.setCookieName(AuthConstants.REMEMBER_ME_COOKIE_NM);
		persistentTokenBasedRememberMeServices.setTokenValiditySeconds(remembermeExpireSeconds);	
		return persistentTokenBasedRememberMeServices;
	}	

	/*
	 * ignore web api define
	 * webSecurity ignoring 설정한 request는 시큐리티 필터체인이 형성되지 않는다.
	 */
	@Override
	public void configure(WebSecurity web) throws Exception {
		
		// security double / 허용
		web.httpFirewall(defaulthttpFirewall());
				
		// string boot가 제공하는 static resource 위치를 모두 ignoring
		web.ignoring()
			.requestMatchers(PathRequest.toStaticResources().atCommonLocations());

		// 추가
		web.ignoring()
			.mvcMatchers(securityIgnoringArray);
	}
	
	/*
	 * spring-security user 인증 규칙 정의
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception
	{
		http.csrf().disable();
		
		http.authorizeRequests()
			.antMatchers(securityPermitallArray).permitAll()
	        .anyRequest().access("@sufinnAuthorizationChecker.check(request, authentication)") // http 요청이 들어올때 마다 권한을 체크한다. 
	        ;
		
		// 권한이 없는 사용자가 접근 시
		http.exceptionHandling()
			.authenticationEntryPoint(authenticationEntryPoint()) // 자격증명 인증 실패시
	    	.accessDeniedHandler(accessDeniedHadler()); //인가실패시
	    
		
		// 세션-쿠키 인증 방식 ==========================================================
	    // login
        http.formLogin()
	    	.loginPage(securityLoginUri)
	    	.loginProcessingUrl(securityLoginProcessUri)
	    	.usernameParameter(loginParamId)
	    	.passwordParameter(loginParamPwd)
	    	.failureHandler(loginFailureHandler())
	    	.successHandler(loginSuccessHandler())
	    	.permitAll()
	    	;
        
	    // logout
        http.logout()
	    	.logoutRequestMatcher(new AntPathRequestMatcher(securityLogoutUri))
	    	.logoutSuccessUrl(securityLoginUri)
	    	.deleteCookies(AuthConstants.JSESSIONID_COOKIE, AuthConstants.REMEMBER_ME_COOKIE_NM)
	    	.invalidateHttpSession(true) // 세션 무효화 (메모리x)
	    	;
        
        // 세션 중복 방지
        if(sessionUse) 
        {
        	http.sessionManagement()
	        	.maximumSessions(sessionMaxCount)
	        	.maxSessionsPreventsLogin(false) // false : 기존 세션 만료 처리
	        	.expiredUrl(securityLoginUri)
	        	.sessionRegistry(sessionRegistry()); // logout시 초기화
        }

        
        // 자동 로그인 설정
        if(remembermeUse)
        {
        	http.rememberMe()
        		.rememberMeServices(customRememberMeServices());
        }
        // END 세션-쿠키 ============================================================
	}
}
  1. 시큐리티 ignore, permitAll 처리
    • ignore에 선언된 url 요청은 시큐리티 필터체인이 생성되지 않는다.
      spring boot의 static resource는 권한체크할 필요가 없으므로 ignoring 처리 하였다.
      사용자가 권한체크하지 않기를 원하는 url을 추가해주었다.
    • permitAll에 선언된 url 요청은 시큐리티 필터체인이 생성되고, 시큐리티로 인해서 모두에게 권한 허용 처리가 된다.
      로그인, 회원가입에 필요한 url을 추가해주었다.
  2. AuthorizationChecker 생성 및 부여
    • ignore와 permitAll을 제외한 모든 요청은 커스텀한 rule을 적용하였다. (SurfinnAuthorizationChecker)
  3. ExceptionHandling 생성 및 부여
    • 자격증명 인증 실패시 EntryPoint를 커스텀하여 적용하였다. (AuthenticationEntryPoint)
    • 인가실패시 Handler를 커스텀하여 적용하였다. (AccessDeniedHandler)
  4. 로그인
    • 로그인은 세션-쿠키 인증방식으로 구현하였다.
    • 로그인 성공/실패 핸들러를 커스텀하여 적용하였다. (LoginFailureHandler, LoginSuccessHandler)
  5. 세션 중복 방지
    • 세션 중복 방지 기능 사용하였다.
    • 기능 사용 여부와 원하는 중복가능 한 개수를 설정할 수 있다.
  6. 자동 로그인
    • 자동 로그인 기능을 사용하였다.
    • 기능 사용 여부와 자동로그인 서비스를 커스텀하여 적용하였다. (CustomRememberMeServices)

 

5. Secutiy 인증/인가 객체 커스텀

spring-secutiy에서 인증/인가 처리할 권한 객체를 커스텀하였다. (CustomUserDetails)

  • CustomUserDetails는 해당 사용자의 권한그룹과 역할인 UserGrantedAuthority을 갖는다.

 

5-1. CustomUserDetails.java

package com.innerwave.surfinn.business.domain;

import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import com.innerwave.surfinn.gen.SurfInnCodes.ACNT_STTS_CD;
import com.innerwave.surfinn.gen.SurfInnCodes.LOGIN_PRVNT_STTS_CD;
import com.innerwave.surfinn.gen.SurfInnCodes.SVC_STTS_CD;

public class CustomUserDetails extends User
{
	private static final long serialVersionUID = 2377821761785224090L;

	private com.innerwave.surfinn.bss.common.domain.User userVO;
	
	/**
	 * 계정상태
	 * Waiting : 승인 대기, Rejected : 승인 거절, Active : 정상, Inactive : 휴면
	 */
	private ACNT_STTS_CD acntStts;
	
	/**
	 * 로그인 방지 상태
	 * Normal : 정상
	 * AccountDisabledByBlackList : 블랙리스트 비활성화 {@link User}.enabled is false
	 * LockedByPassword : 비밀번호 연속 실패 잠김 {@link User}.accountNonLocked is false
	 */
	private LOGIN_PRVNT_STTS_CD loginPrvntStts;
	
	/**
	 * 서비스 상태
	 * Normal : 정상, ServiceExpired : 서비스 만료
	 */
	private SVC_STTS_CD svcStts;

	public CustomUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities) {
		// this account status is 'Active'
		super(username, password, authorities);
		this.initStatus();
	}
	
	public CustomUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities
			, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked) {
		
		super(username, password,  enabled, accountNonExpired,credentialsNonExpired, accountNonLocked, authorities);
		this.initStatus();
	}
	
	private void initStatus()
	{
		this.acntStts = ACNT_STTS_CD.Active;
		this.loginPrvntStts = LOGIN_PRVNT_STTS_CD.Normal;
		this.svcStts = SVC_STTS_CD.Normal;
	}
	
	public com.innerwave.surfinn.bss.common.domain.User getUser() {
		return userVO;
	}

	public void setUser(com.innerwave.surfinn.bss.common.domain.User userVO) {
		this.userVO = userVO;
	}

	public ACNT_STTS_CD getAcntStts() {
		return acntStts;
	}

	public void setAcntStts(ACNT_STTS_CD acntStts) {
		this.acntStts = acntStts;
	}

	public LOGIN_PRVNT_STTS_CD getLoginPrvntStts() {
		return loginPrvntStts;
	}

	public void setLoginPrvntStts(LOGIN_PRVNT_STTS_CD loginPrvntStts) {
		this.loginPrvntStts = loginPrvntStts;
	}

	public SVC_STTS_CD getSvcStts() {
		return svcStts;
	}

	public void setSvcStts(SVC_STTS_CD svcStts) {
		this.svcStts = svcStts;
	}
	
}
  1.  CustomUserDetails는 계정상태, 로그인 방지 상태, 서비스 상태를 갖는다.
    • 계정상태 : 승인대기, 승인거절, 정상, 휴면
    • 로그인 방지 상태 : 정상, 블랙리스트 비활성화, 비밀번호 연속실패 잠김
    • 서비스 상태 : 정상, 서비스 만료
  2. CustomUserDetails는 권한그룹과 역할을 갖는 UserGrantedAuthority를 갖는다.
    • UserGrantedAuthority는 권한그룹(AuthGroup)과 역할(AuthRole)을 갖는다.

5-2. UserGrantedAuthority.java

spring-security의 GrantedAuthority 인터페이스를 따른다.
GrantedAuthority는 spring-security의 인가처리를 위한 권한 인터페이스이다.

UserGrantedAuthority는 GrantedAuthority 인터페이스를 따르며, 권한그룹 AuthGroup과 역할 AuthRole을 가지고 있다.
권한그룹은 메뉴 권한 처리를 위함이고, 역할은 api 권한 처리를 위함이다.

public final class UserGrantedAuthority implements GrantedAuthority{

	private static final long serialVersionUID = -5045653451249831970L;

	private final String role;
	private AuthGroup authGroup;

	
	public UserGrantedAuthority(AuthGroup userGroup)
	{
		this.role = userGroup.getName();
		this.authGroup = userGroup;
	}
	
	@JsonIgnore
	@Override
	public String getAuthority() {
		return this.role;
	}
	
	public AuthGroup getAuthGroup() 
	{
		return this.authGroup;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj instanceof SimpleGrantedAuthority) {
			return obj == this;
		}
		return false;
	}
	
	@Override
	public int hashCode() {
		return this.role.hashCode();
	}
	
	@Override
	public String toString() {
		return this.role;
	}
	
	@JsonIgnore
	public List<AuthRole> getHasRoles()
	{
		return this.authGroup.getAuthRoleList();
	}

}

5-2. AuthGroup.java

권한그룹 객체이며, 각 권한그룹은 역할을 가지고 있다.

@Data
public class AuthGroup {

	private int id;
	private String name;
	
	// 역할
	private List<AuthRole> authRoleList;

	@JsonInclude(Include.NON_NULL)
	private List<AuthGroup> chlidAuthGroupList;
	
	public AuthGroup(int id, String name)
	{
		this.id = id;
		this.name = name;
	}
	
	public boolean hasChlid() {
		return chlidAuthGroupList != null && !chlidAuthGroupList.isEmpty();
	}
}

 

5-3. AuthRole.java

권한그룹이 각각 가지고 있는 역할이다.

@Data
public class AuthRole {

	private String id;
	@JsonIgnore
	private String name;
	
	public AuthRole(String id, String name)
	{
		this.id = id;
		this.name = name;
	}
}

6. 로그인 기능 구현 ~ 7. 로그인 인증 핸들러

2021.10.24 - [IT 기술/권한 인증&인가] - [Spring Security] 로그인 기능 구현

 

[Spring Security] 로그인 기능 구현

6. 로그인 기능 구현 spring-security의 DaoAuthenticationProvider를 커스텀하였다. (CustomAuthenticationProvider) CustomAuthenticationProvider에서는 DB에서 사용자 정보를 가져오기 위한 CustomUserDetail..

yjkim97.tistory.com

 

8. AuthorizationChecker.java ~ 9. 권한이 없는 사용자 접근시 처리

2021.10.24 - [IT 기술/권한 인증&인가] - [Spring Security] 인증 및 권한 체크

 

[Spring Security] 인증 및 권한 체크

2021.10.23 - [IT 기술/권한 인증&인가] - [Spring Security] Authentication 라이브러리 구현 [Spring Security] Authentication 라이브러리 구현 프로젝트마다 로그인/회원가입/권한인증 등 기능을 매번 구현하..

yjkim97.tistory.com

 

'기술 > Spring Security' 카테고리의 다른 글

[Spring security] 인증 및 권한 체크  (0) 2021.10.24
[Spring Security] 로그인 기능 구현  (0) 2021.10.24
[Spring] Spring Security란?  (3) 2021.07.11