> Hello World !!!

     

@syaku

스프링 시큐리티 커스텀 로그인 : Spring Security Custom Login UserDetailsService AuthenticationProvider #4 스프링프레임워크/Spring Framework

written by Seok Kyun. Choi. 최석균

스프링 시큐리티 커스텀 로그인 : Spring Security Custom Login UserDetailsService AuthenticationProvider #4 스프링프레임워크/Spring Framework


개발환경

Mac OS X 10.9.4
JAVA 1.6
Apache Tomcat 7.x
Spring 3.1.1
Spring security 3.1.1
Spring Tool Suite 3.5.1
Maven 2.5.1

이번에는 로그인 처리를 스프링 자체에서 처리하지 않고 직접 비지니스로직을 구현해보도록 한다.

로그인 처리를 직접 구현하는 데 사용하는 인터페이스는 UserDetailsService 와 AuthenticationProvider 가 있다.

인증처리 과정
1) 접속자가 계정과 암호를 입력한다.
2) 접속자가 입력한 계정을 이용하여 데이터베이서에서 사용자 정보를 조회한 결과를 UserDetailsService 담는 다.

정확하게 말해 UserDetailsService 에 담는 것이 아니라 비지니스 로직을 처리하는 일을 하고 UserDetails 라는 곳에 담긴다.
기본적으로 계정 암호 권한룰 그외 몇가지만 설정되어 있어 필요에 따라 항목을 추가하면 된다.

3) 접속자가 입력한 계정과 암호를 사용자 정보의 계정과 암호를 비교하여 일치 한다면 사용자 정보를 사용할 수 있게 허가 한다. 이 일을 담당하는 것이 AuthenticationProvider 가 하는 역활이다.

그래서 나는 UserDetailsService 와 UserDetails 만 직접 개발하고 AuthenticationProvider 는 스프에서 처리하게 구현하였다.

이제 실제 소스를 참고하면 이해가 쉬울 것 이다. (이전 포스팅 소스를 사용하여 연결되는 소스이니 참고한다.)

@소스 servlet-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:security="http://www.springframework.org/schema/security"
    xsi:schemaLocation="
    http://www.springframework.org/schema/mvc 
    http://www.springframework.org/schema/mvc/spring-mvc.xsd
    http://www.springframework.org/schema/beans 
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context 
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd
    ">

    <!-- DispatcherServlet Context: defines this servlet's request-processing infrastructure -->

    <!-- Enables the Spring MVC @Controller programming model -->
    <annotation-driven />

    <security:global-method-security secured-annotations="enabled" pre-post-annotations="enabled" />

    <!-- Handles HTTP GET requests for /resources/** by efficiently serving up static resources in the ${webappRoot}/resources directory -->
    <resources mapping="/resources/**" location="/resources/" />

    <!-- Resolves views selected for rendering by @Controllers to .jsp resources in the /WEB-INF/views directory -->
    <beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <beans:property name="prefix" value="/WEB-INF/views/" />
        <beans:property name="suffix" value=".jsp" />
    </beans:bean>

    <context:component-scan base-package="com.syaku.security" use-default-filters="false">
    <context:include-filter expression="org.springframework.stereotype.Controller" type="annotation" />
    </context:component-scan>

</beans:beans>

@소스 security-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans 
    xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context 
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd
    ">

    <context:component-scan base-package="com.syaku.security.." use-default-filters="false">
    <context:include-filter expression="org.springframework.stereotype.Service" type="annotation" />
    <context:include-filter expression="org.springframework.stereotype.Repository" type="annotation" />
    </context:component-scan>

    <http auto-config="true" use-expressions="true" access-denied-page="/denied">

    <form-login
    login-page="/signin"
    username-parameter="user_id" 
    password-parameter="password"
    login-processing-url="/signin_ok"
    authentication-success-handler-ref="signinSuccessHandler"
    authentication-failure-handler-ref="signinFailureHandler"
    default-target-url="/mypage"
    always-use-default-target="false"
    />

    <logout
    invalidate-session="true"
    logout-success-url="/signin"
    logout-url="/signout" />

    </http>

    <beans:bean id="signinSuccessHandler" class="com.syaku.security.SigninSuccessHandler" />
    <beans:bean id="signinFailureHandler" class="com.syaku.security.SigninFailureHandler">
        <beans:property name="defaultFailureUrl" value="/signin?error=true" />
    </beans:bean>

    <!--
    <authentication-manager>
        <authentication-provider>
            <password-encoder ref="passwordEncoder"/>
            <user-service>
                <user name="guest" password="35675e68f4b5af7b995d9205ad0fc43842f16450" authorities="ROLE_USER"/>
                <user name="admin" password="d033e22ae348aeb5660fc2140aec35850c4da997" authorities="ROLE_ADMIN"/>
            </user-service>
        </authentication-provider>
    </authentication-manager>
    -->

    <beans:bean id="userService" class="com.syaku.security.model.UserService" />

    <beans:bean id="encoder" class="org.springframework.security.authentication.encoding.ShaPasswordEncoder" />
    <beans:bean id="saltSource" class="org.springframework.security.authentication.dao.ReflectionSaltSource">
        <beans:property name="userPropertyToUse" value="username" />
    </beans:bean>

    <authentication-manager>
        <authentication-provider user-service-ref="userService">
            <password-encoder ref="encoder">
                <salt-source ref="saltSource" />
            </password-encoder>
        </authentication-provider>
    </authentication-manager>

</beans:beans>

userDetailsService 빈이 추가되었다. 그리고 암호가 변동되지 않게 salt-source 를 설정하였다.

[2014.11.05] 내용 수정
salt-source 를 설정하지 않으면 입력한 비밀번호 암호된 값이 매번 바뀌게 된다. 변경되지 않아야 암호화된 값을 비교할 수 있어 수정하였다.

@소스 Privilege.java

package com.syaku.security.model.domain;

public class Privilege {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

@소스 Role.java

package com.syaku.security.model.domain;

import java.util.List;
import org.springframework.security.core.GrantedAuthority;


public class Role implements GrantedAuthority {
    private static final long serialVersionUID = 1L;

    private String name;
    private List<Privilege> privileges;

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String getAuthority() {
        return this.name;
    }

    public List<Privilege> getPrivileges() {
        return privileges;
    }
    public void setPrivileges(List<Privilege> privileges) {
        this.privileges = privileges;
    }
}

@소스 User.java

package com.syaku.security.model.domain;

import java.util.Collection;
import java.util.List;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;


public class User implements UserDetails {
    private static final long serialVersionUID = 1L;

    private String username;
    private String password;

    // 추가적인 회원정보 항목을 추가한다. email 이나 연락처 등등...

    private List<Role> authorities;
    private boolean accountNonExpired = true;
    private boolean accountNonLocked = true;
    private boolean credentialsNonExpired = true;
    private boolean enabled = true;

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

    public void setUsername(String username) {
        this.username = username;
    }

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

    public void setPassword(String password) {
        this.password = password;
    }

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

    public void setAuthorities(List<Role> authorities) {
        this.authorities = authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired;
    }

    public void setAccountNonExpired(boolean accountNonExpired) {
        this.accountNonExpired = accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked;
    }

    public void setAccountNonLocked(boolean accountNonLocked) {
        this.accountNonLocked = accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNonExpired;
    }

    public void setCredentialsNonExpired(boolean credentialsNonExpired) {
        this.credentialsNonExpired = credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
}

@소스 UserService.java

package com.syaku.security.model;

import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.syaku.security.model.domain.Role;
import com.syaku.security.model.domain.User;

@Service
public class UserService implements UserDetailsService {
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);

    @Override
    public User loadUserByUsername(final String username) throws UsernameNotFoundException {

        logger.info("username : " + username);

        // 회원 정보 dao 에서 데이터를 읽어 옴.

        // test 값을 암호화함.
        String password = "aabcb987e4b425751e210413562e78f776de6285";

        User user = new User();
        user.setUsername(username);
        user.setPassword(password);

        Role role = new Role();
        role.setName("ROLE_USER");

        List<Role> roles = new ArrayList<Role>();
        roles.add(role);
        user.setAuthorities(roles);

        // 만약 데이터가 없을 경우 익셉션
        //if (user == null) throw new UsernameNotFoundException("접속자 정보를 찾을 수 없습니다.");

        return user;
    }
}

UserService 에서는 사용자 정보를 가져오는 DAO 를 통해 정보를 담는 역활을 한다. 암호는 임의적으로 암호화된 코드를 이용하여 생성하였다.
이제 로그인 테스트를 해보면 정상적인 처리가 이루어 질 것이다. 계정은 test 암호 test 로 접속하여 테스트 한다.

만약 로그인 처리까지 직접 비지니스 로직을 구현하고 싶다면 AuthenticationProvider 이용해서 구현하면 된다.

우선 security-context.xml 에서 authentication-manager 를 아래와 같이 수정한다.

<beans:bean id="encoder" class="org.springframework.security.crypto.password.StandardPasswordEncoder" />

<beans:bean id="userService" class="com.syaku.security.model.UserService" />
<beans:bean id="customAuthenticationProvider" class="com.syaku.security.CustomAuthenticationProvider" />

<!-- 
<authentication-manager>
    <authentication-provider user-service-ref="userService">
        <password-encoder ref="encoder">
            <salt-source ref="saltSource" />
        </password-encoder>
    </authentication-provider>
</authentication-manager>
-->

<authentication-manager alias="authenticationManager">
    <authentication-provider ref="customAuthenticationProvider" />
    <authentication-provider user-service-ref="userService">
        <password-encoder ref="encoder">
            <salt-source ref="saltSource" />
        </password-encoder>
    </authentication-provider>
</authentication-manager>

authenticationManager 에 customAuthenticationProvider 가 추가되었고 빈도 추가되었다.

[2015.03.03] 계정이 없다는 UsernameNotFoundException 아무리 호출해도... onAuthenticationFailure 에서는 BadCredentialsException 자꾸 찾길래... 원인은 customAuthenticationProvider 밑에 있는 userService 였다!! 그래서 그부분을 주석 처리한다!!!

<authentication-manager alias="authenticationManager">

    <authentication-provider ref="customAuthenticationProvider" />

    <!--

    <authentication-provider user-service-ref="userService">

        <password-encoder ref="encoder">

            <salt-source ref="saltSource" />

        </password-encoder>

    </authentication-provider>

    -->

</authentication-manager>




@소스 CustomAuthenticationProvider.java

package com.syaku.security;

import java.util.Collection;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.SaltSource;
import org.springframework.security.authentication.encoding.PasswordEncoder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import com.syaku.security.model.UserService;
import com.syaku.security.model.domain.User;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationProvider.class);

    @Autowired
    UserService userService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private SaltSource saltSource;

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

        User user;
        Collection<? extends GrantedAuthority> authorities;

        try {

            user = userService.loadUserByUsername(username);

            String hashedPassword = passwordEncoder.encodePassword(password, saltSource.getSalt(user));

            logger.info("username : " + username + " / password : " + password + " / hash password : " + hashedPassword);
            logger.info("username : " + user.getUsername() + " / password : " + user.getPassword());

            if (!hashedPassword.equals(user.getPassword())) throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");

            authorities = user.getAuthorities();
        } catch(UsernameNotFoundException e) {
            logger.info(e.toString());
            throw new UsernameNotFoundException(e.getMessage());
        } catch(BadCredentialsException e) {
            logger.info(e.toString());
            throw new BadCredentialsException(e.getMessage());
        } catch(Exception e) {
            logger.info(e.toString());
            throw new RuntimeException(e.getMessage());
        }

        return new UsernamePasswordAuthenticationToken(user, password, authorities);
    }

    @Override
    public boolean supports(Class<?> arg0) {
        return true;
    }
}

모든 작업이 완료되었다. 다음에는 서비스에 대한 권한을 커스텀하게 설정할 수 있는 방법을 구현해보도록 하겠다.

[추가] access-denied-page 대신 access-denied-handler 사용하기

access-denied-handler 는 알겠지만 권한이 없는 페이지에 접속했을 때 호출되는 기능이다. page 와 다르게 백그라운드에서 구동되는 핸들러이다. 그래서 ajax 구현할때 유용하게 사용할 수 있다.

// access-denied-page 속성을 지운다.
<http auto-config="true" use-expressions="true" access-denied-page="/denied">

... 내용 생략 ...

// http 사이에 access-denied-handler 추가한다.
<access-denied-handler ref="accessFailureHandler" />
</http>

<beans:bean id="accessFailureHandler" class="com.syaku.tag.user.AccessFailureHandler">
// access-denied-page 페이지도 적용하고 싶다면 아래와 같이 추가하면 된다.
<beans:property name="errorPage" value="/403" />
</beans:bean>

이전에 만들었던 SigninFailureHandler,SigninSuccessHandler 와 크게 다르지 않다. 참고해서 개발하면 된다.

@소스 AccessFailureHandler.java

package com.syaku.tag.user;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

public class AccessFailureHandler implements AccessDeniedHandler {
    private static final Logger logger = LoggerFactory.getLogger(AccessFailureHandler.class);

    private String errorPage;

    public AccessFailureHandler() {
    }

    public AccessFailureHandler(String errorPage) {
        this.errorPage = errorPage;
    }

    public String getErrorPage() {
        return errorPage;
    }

    public void setErrorPage(String errorPage) {
        this.errorPage = errorPage;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {

        String accept = request.getHeader("accept");

        String error = "true";
        String message = exception.getMessage();

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding("UTF-8");

            String data = StringUtils.join(new String[] {
                " { \"response\" : {",
                " \"error\" : " , error , ", ",
                " \"message\" : \"", message , "\" ",
                "} } "
            });

            PrintWriter out = response.getWriter();
            out.print(data);
            out.flush();
            out.close();

    }
}
스프링 시큐리티 새로운 암호화 체계

설정

<beans:bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
@Autowired private PasswordEncoder passwordEncoder;

// 입력한 암호를 암호화 한다.
String bcryptPassword = passwordEncoder.encode(password);

// matches 를 이용하여 암호를 비교한다.
if ( !passwordEncoder.matches(password, consumer.getPassword()) ) {
    throw new BadCredentialsException( "암호가 일치하지 않습니다." );
}

스프링 시큐리티의 새로운 암호화 방식은 암호화를 할때마다 새로운 암호화된 코드가 생성된다.
그래서 saltSource 를 사용하지 않아도 되며 비교할때는 꼭 matches 메서드를 이용해야 한다.

[참고] 최초로 암호화된 정보를 디비에 저장하고 그 후에는 저장하지 않고 비교만 하면된다.

PS. ㅡㅡ 참 좋은 하루패드가 점점 이상해지네요;; 설정이 초기화될때도 많고... 코드하이라이트가 저렇게 두줄이나오고 좋았는데... 이제 뭘로 해야하나;;;

posted syaku blog

Syaku Blog by Seok Kyun. Choi. 최석균.

http://syaku.tistory.com