[Spring Boot] (4) 로그인/회원가입 - 자사 로그인 :: 음악학원 홈페이지 프로젝트

2020. 8. 15. 22:02Spring Boot

https://codecrafting.tistory.com/9 에서 이어지는 포스팅입니다.

 

[Spring Boot] (2) 로그인/회원가입 - Spring Security 설정 :: 음악학원 홈페이지 프로젝트

들어가기 전에 ... 스프링 시큐리티 Spring Security에 대해 스프링 시큐리티를 이용하면 개발시에 필요한 사용자의 인증, 권한, 보안 처리를 간단하지만 강력하게 구현 할 수 있습니다. 일반적인 웹

codecrafting.tistory.com

지난 포스팅에서는 간단하게 자사 회원가입 서비스에 대한 과정을 설명했습니다. 이번 포스팅에서는 회원가입에 이어 로그인을 진행하겠습니다. Security Config 설정 포스팅에서 설명했듯이 Spring Security Filter를 통한 로그인 인증시에는 새로운 SecurityContect 객체를 생성하여 SecurityContextHolder에 저장합니다. 

 


로그인 절차

0. /src/main/resources/templates/soriel_Login_page.html

    <body>
        <div class="container">
            <th:block th:replace="fragment/soriel_Header :: headerFragment"></th:block>

            <div class="wrapper fadeInDown">
                <div id="formContent">

                    <form method="post" action="/user/login" style="padding-top: 20px;">
                        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

                        <a style="margin-bottom:15px" class="fadeIn first" href="/oauth2/authorization/kakao"><img th:src="@{/static/images/kakao_login_medium_wide.png}"> </a>
                        <input type="text" id="login" class="fadeIn second" name="username" placeholder="아이디를 입력하세요"/>
                        <input type="password" id="password" class="fadeIn third" name="password" placeholder="비밀번호를 입력하세요"/>
                        <input style="margin-bottom:0px" type="submit" class="fadeIn fourth" value="Log In" />
                    </form>

                    <div id="formFooter">
                        <a class="underlineHover" href="/user/sign_up_page">회원가입 하러가기</a><br>

                    </div>
                </div>
            </div>
        </div>
    </body>

로그인 창에서 아이디와 비밀번호를 입력합니다. 이때 아이디의 name 속성을 "username", password 속성을 "password"로 설정합니다. Spring Security가 인식하는 아이디와 패스워드의 기본값이기 때문인데, 사용자가 이를 커스텀할 여지도 물론 남겨두고 있습니다.

 

SecurityConfig

	...                
                .and()
                    .formLogin()
                    .loginPage("/user/login_page")
                    .loginProcessingUrl("/user/login")
                    .defaultSuccessUrl("/")
                    .permitAll()
	...

지난 포스팅에서 설정한 SecurityConfig 클래스의 부분입니다. 여기서 usernameParameter()와 passwordParameter() 메소드의 인자로 사용자가 지정할 name값을 각각 입력해주면 됩니다. 

 

1. /src/main/java/com/soriel/music/springboot/service/MemberSerivce

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<IntegrationEntity> integrationEntityOptional = integrationRepository.findByName(username);
        IntegrationEntity integrationEntity = integrationEntityOptional.orElse(null);

        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(integrationEntity.getRoleKey()));

        return new CustomIntegrationDto(integrationEntity, integrationEntity.getName(), integrationEntity.getUpwd(), authorities);
    }

loadUserByUsername() 메소드에서는 로그인 시의 아이디를 인자로 받아 아이디에 해당하는 유저 데이터를 데이터베이스에서 받아옵니다. 그런데 loadUserByUsername() 메소드는 조금 뜬금없습니다. 왜냐하면 /login 로 접근한 후 사용자가 작성한 코드 그 어디에서도 이 메소드를 호출하는 부분이 없기 때문입니다. 그렇다면 Spring Security는 어떠한 과정을 거쳐서 사용자가 입력한 정보를어떻게 Spring Security Filter로 전달하고 내부적으로는 어떠한 흐름으로 로그인 과정을 수행할 수 있을까요?

 

이제부터 설명할 로그인 과정은 꽤 복잡합니다 ...

ProviderManager 클래스 다이어그램

Post방식으로 login을 시도하면 아이디와 비밀번호가 Authentication의 객체로 객체화되어 ProviderManager.authenticate(Authentication authentication) 가 호출됩니다. 이 메소드에서 다시 AuthenticationProvider의 authenticate(Authentication authentication) 메소드로 흐름이 넘어가는데, 이 AuthenticationProvider를 구현한 AbstractUserDetailsAuthenticationProvider의 additionalAuthenticationChecks(UsernamePasswordAuthenticationToken authentication) 메소드의 인자로 호출됩니다.

 

DaoAuthenticationProvider 클래스 다이어그램

그런데 DaoAuthenticationProvider 클래스는 AbstractUserDetailsAuthenticationProvider를 상속하고 있는데 여기에서 인자로 넘어오는 UsernamePasswordAuthenticationToken authentication에 대해 additionalAuthenticationChecks 메소드와 retrieveUser 메소드를 실행합니다. additionalAuthenticationChecks() 메소드에서는 matches()를 이용하여 저장된 비밀번호와 사용자가 입력한 비밀번호(loadUserByUsername 메소드에서 리턴되는 UserDetail의 비밀번호)를 비교합니다. 이때 실패하면 BadCredentialsException을 throw하고 흐름을 종료합니다.

 

retrieveUser 메소드는 인자인 String username을 이용하여 UserDetailService의 loadUserByUsername 메소드를 실행합니다. 이때의 시도가 성공적으로 이루어지면 다시 ProviderManager 클래스의 authenticate() 메소드로 다시 리턴됩니다. 이때 리턴타입인 UserDetail과, 로그인 시도시의 아이디와 비밀번호를 객체화한 Authentication객체가 동일 클래스의 copyDetail의 인자로 넘어갑니다. 이 메소드에서 AbstractAuthenticationToken을 생성하고 이를 확장한 AbstractAuthenticationToken의 필드인 principal과 credential에 값을 전달한 후 이를 세션에 저장하고, 다시 AbstractAuthenticationProcessingFilter의 protected void successfulAuthentication(HttpServletRequest request,  HttpServletResponse response, Filter chain, Authentication authResult) 를 통해 SpringContext()에 저장합니다.

 

이 과정이 완료되고 나면 User클래스 객체에 필요한 정보들을 담아 웹 어플리케이션 전체에서 사용할 수 있게끔 데이터를 삽입하고, Security Context()에 해당 객체를 넘겨줌으로써  로그인이 완료됩니다. 이 과정이 끝나면 SpringSecurity에서 설정한 로그인 성공페이지로 리다이렉트 시켜줍니다.

 

 

그런데...

예제에서는 조금 이상한 부분이 보입니다. loadUserByUsername 메소드에서 최종적으로 리턴되는 객체가 User 이 아니라 CustomIntegrationDto 로 되어있습니다.

...
        return new CustomIntegrationDto(integrationEntity, integrationEntity.getName(), integrationEntity.getUpwd(), authorities);
}

CustomIntegrationDto는 필자가 따로 만든 커스텀 클래스입니다. 일반적인 로그인과 회원가입에 있어서는 이러한 커스텀 클래스는 필요하지 않습니다. 하지만 추후에 구현할 카카오 로그인에서도 loadUserByUsername 메소드를 거치는데, 이때 리턴할 객체가 하나로 통합되어야 합니다. 그래서 일반 회원가입과 로그인, 카카오 로그인 시 필요한 DTO를 통합한 것이 CustomIntegrationDto 입니다.

 

2. /src/main/java/com/soriel/music/springboot/web/dto/member/CustomIntegrationDto

@Getter
public class CustomIntegrationDto implements IntegrationKakaoAndMenber{

    private Long id;
    private String customName;
    private String upwd;
    private DefaultOAuth2User defaultOAuth2User;
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private final Set<GrantedAuthority> authorities;

    public CustomIntegrationDto(IntegrationEntity integrationEntity, String username, String password, Collection<? extends GrantedAuthority> authorities) {
        new User(username, password, authorities);
        this.customName = integrationEntity.getName();
        this.upwd = integrationEntity.getUpwd();
        this.id = integrationEntity.getId();
        this.authorities = Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities)));
    }
    
    private Set<GrantedAuthority> sortAuthorities(Collection<? extends GrantedAuthority> authorities) {
        SortedSet<GrantedAuthority> sortedAuthorities = new TreeSet<>(Comparator.comparing(GrantedAuthority::getAuthority));
        sortedAuthorities.addAll(authorities);
        return sortedAuthorities;
    }

    @Override
    public void eraseCredentials() {
        this.upwd = null;
    }

    @Override
    public String getPassword() {
        return this.upwd;
    }

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

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

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

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

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

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

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null;
    }

}

기존에 쓰일 User 클래스에서 상속받는 클래스들을 그대로 재구현하기 위해 IntegrationKakaoAndMenber라는 인터페이스를 만들고, 이 인터페이스에 UserDetails, CredentialsContainer 클래스를 다중상속 했습니다. 

 

또한 User 의 일부 메소드를 CustomIntegrationDto 내로 옮겼습니다. 아래는 User 클래스 일부입니다. 

 

2-1. org/springframework/security/core/userDetails/User

public class User implements UserDetails, CredentialsContainer {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private static final Log logger = LogFactory.getLog(User.class);

	// ~ Instance fields
	// ================================================================================================
	private String password;
	private final String username;
	private final Set<GrantedAuthority> authorities;
	private final boolean accountNonExpired;
	private final boolean accountNonLocked;
	private final boolean credentialsNonExpired;
	private final boolean enabled;

	// ~ Constructors
	// ===================================================================================================

	/**
	 * Calls the more complex constructor with all boolean arguments set to {@code true}.
	 */
	public User(String username, String password,
			Collection<? extends GrantedAuthority> authorities) {
		this(username, password, true, true, true, true, authorities);
	}
    
    	public Collection<GrantedAuthority> getAuthorities() {
		return authorities;
	}

	public String getPassword() {
		return password;
	}

	public String getUsername() {
		return username;
	}

	public boolean isEnabled() {
		return enabled;
	}

	public boolean isAccountNonExpired() {
		return accountNonExpired;
	}

	public boolean isAccountNonLocked() {
		return accountNonLocked;
	}

	public boolean isCredentialsNonExpired() {
		return credentialsNonExpired;
	}

	public void eraseCredentials() {
		password = null;
	}
    
    	private static SortedSet<GrantedAuthority> sortAuthorities(
			Collection<? extends GrantedAuthority> authorities) {
		Assert.notNull(authorities, "Cannot pass a null GrantedAuthority collection");
		// Ensure array iteration order is predictable (as per
		// UserDetails.getAuthorities() contract and SEC-717)
		SortedSet<GrantedAuthority> sortedAuthorities = new TreeSet<>(
				new AuthorityComparator());

		for (GrantedAuthority grantedAuthority : authorities) {
			Assert.notNull(grantedAuthority,
					"GrantedAuthority list cannot contain any null elements");
			sortedAuthorities.add(grantedAuthority);
		}

		return sortedAuthorities;
	}
    
...

 

다시 CustomIntegrationDto로 넘어와서 코드를 보면, new User 클래스의 생성자로 아이디와 비밀번호, 그리고 권한까지 넘겨줌으로써 Spring Security 내부 설정을 먼저 완료했습니다. 또 클래스에서는 로그인 시에 사용했던 IntegrationEntity 객체를 그대로 인자로 받아서 사용하고  있습니다.

 

3. /src/main/java/com/soriel/music/springboot/domain/member/IntegrationEntity

@Getter
@Entity(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class IntegrationEntity extends BaseTimeEntity {
    //가제

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, unique = true, nullable = false)
    private String email;

    @Column(nullable = false, unique = true)
    private String name;

    @Column(nullable = true, length = 200)
    private String upwd;

    @Enumerated(EnumType.STRING)
    @Column
    private Role role;

    @Builder
    public IntegrationEntity(Long id, String email, String name, String upwd, Role role) {
        this.id = id;
        this.email = email;
        this.name = name;
        this.upwd = upwd;
        this.role = role;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }

    public IntegrationEntity update(String name, String email) {
        this.name = name;
        this.email = email;

        return this;
    }
}

 

이는 웹 어플리케이션에서 변수로 사용가능 하게끔 customName, upwd, id, authority 필드를 만들어 인증된 아이디, 비밀번호, pk, ROLE을 Entitiy에서 get하여 값으로써 넘겨주기 위함입니다. 이제 개발자는 Authentication 클래스의 Principal 객체에 쌓여있는 이러한 필드값들을 꺼내서 사용할 수 있게 되었습니다.

 

이렇게 사용할 수 있습니다.

<span id="welcome" sec:authentication="principal.customName"></span>님 환영합니다.&nbsp;

 

 

이렇게 해서 Spring Security를 이용한 자체 회원가입과 로그인을 모두 마쳤습니다. 다음 포스팅에서는 OAuth2를 이용한 카카오 로그인에 대해 다뤄보도록 하겠습니다.