🌿Spring Security
지금까지 사용한 인증 방식은 세션 기반으로 직접 처리하는 방식을 사용했다. 이 방식은 자바가 아니더라도 다른 언어나 플랫폼에서 대부분 인증 처리에 사용하는 방식이기도 하다.
Spring Framework는 반복적인 행동에 대한 틀을 제공하며, 마찬가지로 Spring에서 인증에 관련한 처리에 대한 틀로서 Spring Security Project를 제공한다.
모든 회원이 접근할 수 있는 경로와 가입된 회원이 접근할 수 있는 경로, 관리자 권한이 있어야 접근할 수 있는 경로를 만들어 보면서 권한(Role)을 부여하고, Security를 구현해 보도록 하자.
🌿프로젝트 설정
pom.xml
<!-- Security -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
Spring Security를 사용하기 위해서는 의존성을 4개 추가해 주어야 한다.
root.context.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mybatis-spring="http://mybatis.org/schema/mybatis-spring"
xsi:schemaLocation="http://mybatis.org/schema/mybatis-spring http://mybatis.org/schema/mybatis-spring-1.2.xsd
http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- Root Context: defines shared resources visible to all other web components -->
<bean id="hikariConfig" class="com.zaxxer.hikari.HikariConfig">
<property name="driverClassName"
value="net.sf.log4jdbc.sql.jdbcapi.DriverSpy"></property>
<property name="jdbcUrl"
value="jdbc:log4jdbc:oracle:thin:@localhost:1521:xe"></property>
<property name="username" value="hr"></property>
<property name="password" value="java1234"></property>
</bean>
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource"
destroy-method="close">
<constructor-arg ref="hikariConfig"></constructor-arg>
</bean>
<bean id="sessionfactory"
class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"></property>
<!-- <property name="mapperLocations"
value="classpath*:mapper/*.xml"></property> -->
</bean>
<!-- <bean class="org.mybatis.spring.SqlSessionTemplate">
<constructor-arg ref="sessionfactory"></constructor-arg>
</bean> -->
<mybatis-spring:scan base-package="com.test.mapper"/>
</beans>
어노테이션 매퍼를 사용하기 위한 작업을 해 주었다.
servlet-context.xml
패키지
- com.test.controller
- com.test.persistence
- com.test.domain
- com.test.mapper
- com.test.security
<?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"
xsi:schemaLocation="http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<!-- DispatcherServlet Context: defines this servlet's request-processing infrastructure -->
<!-- Enables the Spring MVC @Controller programming model -->
<annotation-driven />
<!-- 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.test.spring" />
<context:component-scan base-package="com.test.controller" />
<context:component-scan base-package="com.test.persistence" />
<context:component-scan base-package="com.test.security" />
</beans:beans>
servlet-context.xml에서 앞으로 만들 패키지에 대한 스캔을 해 주었다.
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee https://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<!-- The definition of the Root Spring Container shared by all Servlets
and Filters -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
/WEB-INF/spring/root-context.xml
/WEB-INF/spring/security-context.xml
</param-value>
</context-param>
<!-- Creates the Spring Container shared by all Servlets and Filters -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener
</listener-class>
</listener>
<!-- Processes application requests -->
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet
</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/appServlet/servlet-context.xml
</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter
</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<servlet-name>appServlet</servlet-name>
</filter-mapping>
<!-- Security Filter -->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy
</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
스프링 시큐리티가 동작할 수 있도록 필터를 추가했다.
이때 반드시 Encoding Filter의 다음에 Security Filter를 작성해야 한다.
이외의 log4j, ojdbc 등의 프로젝트 설정은 이전의 프로젝트를 참고한다.
security-context.xml 파일 생성
- /WEB-INF/spring/security-context.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
</beans>
스프링 시큐리티의 전반적인 설정을 할 파일이다. 로그인, 로그아웃 같은 작업을 위 파일에서 하게 된다.
xml 파일을 생성할 때 Spring Bean Configuration File로 만들어야 한다.
해당 xml 파일의 Namespaces에서 security를 추가해 주어야 하며, spring-security-5.0으로 되어 있는 것을 spring-security로 반드시 변경해 주어야 한다.
에러 발생
- org.springframework.beans.factory.NoSuchBeanDefinitionException
서버를 시작하면 스프링 설정 파일을 읽어 들이지 못했기 때문에 에러가 발생한다.
이는 security-context.xml을 찾지 못해서 에러가 발생하고 있는 것이다. 이에 대한 세팅이 필요하며, 이를 web.xml에서 하게 된다.
기본 코드
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 폼 로그인 방식으로 보안을 구성하겠습니다. -->
<security:http>
<security:form-login/>
</security:http>
<!-- 계정 관리 매니저(회원 아이디/비밀번호/권한등급 등 관리) -->
<security:authentication-manager>
</security:authentication-manager>
</beans>
그리고 security-context.xml의 기본 구문을 작성해 주면 에러가 발생하지 않고 정상 실행된다.
기본적인 URI 설계
- /index.do: 초기 페이지 (모두 접근)
- /member/member.do: 회원 페이지(회원과 관리자 접근)
- /admin/admin.do: 관리자 페이지(관리자만 접근)
파일 생성
- script.sql
com.text.controller
- TestController.java
- AuthController.java
- MemberController.java
com.test.spring
- MemberTest.java
com.test.security
- CustomUserDetailsService.java
com.test.domain
- MemberDTO.java
- AuthDTO.java
- CustomUser.java
com.test.mapper
- TestMapper.java (I)
- MemberMapper.java (I)
com > test > mapper
- TestMapper.xml
- MemberMapper.xml
views
- index.jsp
- member > member.jsp
- admin > admin.jsp
- inc > header.jsp
- auth > accesserror.jsp
- auth > mylogin.jsp
- auth > mylogout.jsp
- auth > register.jsp
- auth > myinfo.jsp
TestMapper.xml을 생성할 때에는 폴더를 하나씩 만들어 주어야 오류가 발생하지 않는다.
🌿작업 순서
1. TestController.java: Spring Tiles로 모든 사용자(index), 회원(member), 관리자(admin) 페이지 경로 작성
2. header.jsp: 링크가 걸려 있는 메뉴 헤더 생성
3. index.jsp, member.jsp, admin.jsp: 시작 페이지, 회원 페이지, 관리자 페이지 생성 및 기본 코드 작성
4. security-context.xml: security-intercept-url를 이용해 경로에 적절한 권한 추가
5. security-context.xml: 회원 계정 추가
6. security-context.xml: 관리자(여러 권한을 가지는 사용자) 추가
7. AuthController.java, accesserror.jsp: 연결 에러 페이지에 다른 페이지를 보여주는 것으로 어떤 상황이 발생했는지 공지
8. AuthController.java, mylogin.jsp, security-context.xml: 로그인 페이지 구현 (커스텀 로그인 페이지)
9. mylogin.jsp: CSRF 처리
10. CustomLoginSuccessHandler.java, security-context.xml: 로그인 성공 이후 처리
11. AuthController.java, mylogout.jsp, security-context.xml: 로그아웃 처리
12. script.sql: DB 계정 정보(오라클)를 시큐리티에 연동하기 위한 테이블 생성
13. security-context.xml: 로그인할 때 사용할 암호 인코딩 객체 생성
14. MemberDTO, AuthDTO: 테이블 컬럼 작성
15. MemberTest.java, TestMapper.java, TestMapper.xml: xml 파일 연동 및 암호를 인코딩한 상태로 저장하기 위해 회원 가입 구현 전 임시 계정을 생성(3명), add 및 addAuth 작업
16. MemberController.java, register.jsp: 회원 가입 구현
17. CustomUserDetailsService.java, CustomUser.java, MemberMapper.xml, security-context.xml: 커스텀 UserDetailsService 사용 (DB 사용 로그인 후 처리)
18. header.jsp: 로그인한 계정의 권한에 따라 보여주는 링크 변경
🌿security-context.xml
security:intercept-url
- patten 속성: 접근할 URI
- access 속성: 표현식, 접근 권한
<!-- 폼 로그인 방식으로 보안을 구성하겠습니다. -->
<security:http>
<!-- 어떤 경로에 어떤 권한을 줄 지 결정 -->
<security:intercept-url pattern="/index.do" access="permitAll"/>
<security:intercept-url pattern="/member/member.do" access="hasRole('ROLE_MEMBER')"/>
<security:form-login/>
</security:http>
security:intercept-url는 어떤 경로에 어떤 권한을 줄지 결정한다.
index의 access가 permitAll인 것은 모든 사람이 들어갈 수 있는 페이지임을 의미한다. 그리고 member에는 hasRole('ROLE_MEMBER')을 부여했다. 이는 인증 과정을 거쳐서 member라는 자격을 갖춘 사람에게만 페이지를 접근할 수 있는 권한을 허용하겠다는 의미이다.
이제 member에 들어가면 권한이 없기 때문에 내장된(Built-in) login 페이지로 이동한다. 이 페이지는 디자인을 할 수 없기 때문에 테스트용으로 사용하면 된다.
현재 쉽게 말하면 아직 인증 티켓을 가지고 있지 않은 상태인 셈이다. 단순 로그인을 내장 구현하여 사용해 보도록 하자.
이곳에서 명시되지 않은 URI는 모든 사용자가 접근 가능하므로 index.do는 작성하지 않아도 되지만, 명확하게 하기 위해 작성하였다.
단순 로그인: 내장 구현
주의점! 일반 시스템에서의 id를 스프링 시큐리티에서는 username이라는 예약어를 사용한다. 이 부분이 헷갈릴 수 있으므로 유의하도록 하자.
security-context.xml에서 계정을 추가하는 작업을 한다.
<!-- 계정 관리 매니저(회원 아이디/비밀번호/권한등급 등 관리) -->
<security:authentication-manager>
<security:authentication-provider>
<security:user-service>
<security:user name="isaac" password="{noop}1111" authorities="ROLE_MEMBER"/>
</security:user-service>
</security:authentication-provider>
</security:authentication-manager>
security:authentication-manager는 관리자 역할을 하고, security:authentication-provider는 제공자이며, 여기서 제공하는 것 중에 하나가 security:user-serviced이다.
여기에 작성하는 authorities가 로그인할 때 받을 Role의 이름이다. 앞서 ROLE_MEMBER를 추가했으므로 authorities 또한 ROLE_MEMBER로 추가하도록 한다. 이제 isaac, 1111로 로그인할 수 있다.
Spring Security는 password에 암호화를 하지 않으면 에러가 발생하게 되어 있는데, 대신 {noop}를 붙이면 테스트를 위해서 암호화를 하지 않아도 사용할 수 있게 해 준다.
인증 티켓 삭제
애플리케이션의 쿠키에서 JSESSIONID를 삭제하면 인증 티켓이 삭제된다.
테스트를 할 때 해당 인증 티켓을 삭제하면서 테스트를 하면 된다.
여러 권한을 가지는 사용자
관리자는 일반 회원의 권한과 관리자 권한을 모두 가진다.
관리자 계정을 만들어 보도록 하자.
<!-- 폼 로그인 방식으로 보안을 구성하겠습니다. -->
<security:http>
<!-- 어떤 경로에 어떤 권한을 줄 지 결정 -->
<security:intercept-url pattern="/index.do" access="permitAll"/>
<security:intercept-url pattern="/member/member.do" access="hasRole('ROLE_MEMBER')"/>
<security:intercept-url pattern="/admin/admin.do" access="hasRole('ROLE_ADMIN')"/>
<security:form-login/>
</security:http>
만약 admin페이지에서 member 계정으로 접근하면 403 에러가 발생한다.
403 에러는 권한이 없는데 접근하면 발생하는 에러이다.
<!-- 계정 관리 매니저(회원 아이디/비밀번호/권한등급 등 관리) -->
<security:authentication-manager>
<security:authentication-provider>
<security:user-service>
<security:user name="isaac" password="{noop}1111" authorities="ROLE_MEMBER"/>
<security:user name="admin" password="{noop}1111" authorities="ROLE_ADMIN"/>
</security:user-service>
</security:authentication-provider>
</security:authentication-manager>
이렇게 하면 admin으로 로그인하면 ROLE_ADMIN만을 가지기 때문에 관리자 페이지에는 로그인할 수 있지만, 회원 페이지에는 로그인할 수 없게 된다.
<security:intercept-url pattern="/admin/admin.do" access="hasRole('ROLE_ADMIN, ROLE_MEMBER')"/>
어떤 게 상위 그룹이고 하위 그룹인지는 시스템이 아니라 우리가 직접 지정해 주어야 한다.
이때 상위를 표현하지 않고, 콤마를 사용하여 권한을 추가로 부여하는 방식을 사용한다.
403 페이지 대체
<!-- 403 페이지 처리 -->
<security:access-denied-handler error-page="/auth/accesserror.do"/>
security:access-denied-handler로 403이 발생했을 때 페이지를 연결할 수 있다.
등급이 있는 사이트를 만들다보면 접근 권한이 없음에도 접근하는 경우가 있기 때문에 우리가 직접 만든 페이지를 보여주는 게 적절하다.
로그인 페이지 구현
- 커스텀 로그인 페이지
<!-- 빌트인 로그인 페이지 호출 -->
<!-- <security:form-login/> -->
<!-- 사용자 정의 로그인 페이지 호출 -->
<security:form-login login-page="/auth/myLogin.do"/>
접근 제한(403) 페이지처럼 직접 특정 URI를 지정할 수 있다.
login-page를 이용하여 로그인 페이지로 연결하였다.
로그인 성공 이후 처리
- 일반 회원 > member.do로 이동
- 관리자 > admin.do로 이동
로그인에 성공하면 정해진 URI로 이동하게 한다.
<!-- 로그인 성공 후 처리하는 담당자 -->
<bean id="customLoginSuccess" class="com.test.security.CustomLoginSuccessHandler"></bean>
<security:form-login login-page="/auth/mylogin.do" authentication-success-handler-ref="customLoginSuccess"/>
<bean> 태그로 customLoginSuccess를 만들고, CustomLoginSuccessHandler와 연결하였다.
로그아웃 처리
<!-- 로그아웃 처리 -->
<security:logout logout-url="/auth/mylogout.do" logout-success-url="/index.do" invalidate-session="true"/>
invalidate-session="true"를 해 주어야 세션에 남아있는 인증 정보를 없앨 수 있다.
암호 인코딩 객체 생성
- BCryptPasswordEncoder
<!-- 암호 인코딩 객체 -->
<bean id="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>
해당 빈 객체가 암호를 건네주면 알아보기 힘든 이상한 문자열로 바꿔준다.
블로피시(blowfish) 해시 함수를 기반으로 인코딩을 하는 객체이다.
보통 암호화는 2종류가 있다. 복호화가 가능한 암호화가 있고, 복호화가 불가능한 암호화가 있다.
이번에 하는 암호화는 암호화는 가능한데, 복호화는 불가능한 방법이다.
암호를 잃어버리면 찾아드릴 방법이 없다는 말을 하는 경우가 간혹 있는데, 이때 사용한 방법으로 암호화를 구현했기 때문이다.
사용자 로그인 객체
<!-- 사용자 로그인 객체 -->
<bean id="customUserDetailsService" class="com.test.security.CustomUserDetailsService"></bean>
🌿전체 코드
security-context.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 로그인 성공 후 처리하는 담당자 -->
<bean id="customLoginSuccess" class="com.test.security.CustomLoginSuccessHandler"></bean>
<!-- 암호 인코딩 객체 -->
<bean id="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>
<!-- 사용자 로그인 객체 -->
<bean id="customUserDetailsService" class="com.test.security.CustomUserDetailsService"></bean>
<!-- 폼 로그인 방식으로 보안을 구성하겠습니다. -->
<security:http>
<!-- 어떤 경로에 어떤 권한을 줄 지 결정 -->
<security:intercept-url pattern="/index.do" access="permitAll" />
<security:intercept-url pattern="/member/member.do" access="hasRole('ROLE_MEMBER')" />
<security:intercept-url pattern="/admin/admin.do" access="hasRole('ROLE_ADMIN')" />
<!-- 이곳에서 명시되지 않은 URI은 모든 사용자가 접근 가능 -->
<!-- 빌트인 로그인 페이지 호출 -->
<!-- <security:form-login/> -->
<!-- 사용자 정의 로그인 페이지 호출 -->
<!-- <security:form-login login-page="/auth/mylogin.do" /> -->
<security:form-login login-page="/auth/mylogin.do" authentication-success-handler-ref="customLoginSuccess" />
<!-- 로그아웃 처리 -->
<security:logout logout-url="/auth/mylogout.do" logout-success-url="/index.do" invalidate-session="true"/>
<!-- 403 페이지 처리 -->
<security:access-denied-handler error-page="/auth/accesserror.do"/>
</security:http>
<!-- 계정 관리 매니저(회원 아이디/비밀번호/권한등급 등 관리) -->
<!--
<security:authentication-manager>
<security:authentication-provider>
<security:user-service>
<security:user name="isaac" password="{noop}1111" authorities="ROLE_MEMBER"/>
<security:user name="admin" password="{noop}1111" authorities="ROLE_ADMIN, ROLE_MEMBER"/>
</security:user-service>
</security:authentication-provider>
</security:authentication-manager>
-->
<security:authentication-manager>
<security:authentication-provider user-service-ref="customUserDetailsService">
<security:password-encoder ref="bcryptPasswordEncoder" />
</security:authentication-provider>
</security:authentication-manager>
</beans>
TestController.java
package com.test.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TestController {
@GetMapping(value = "/index.do")
public String index(Model model) {
//모든 사용자
return "index";
}
@GetMapping(value = "/member/member.do")
public String member(Model model) {
//회원 전용 + 관리자 전용
return "member/member";
}
@GetMapping(value = "/admin/admin.do")
public String admin(Model model) {
//관리자 전용
return "admin/admin";
}
}
AuthController.java
package com.test.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AuthController {
@GetMapping(value = "/auth/accesserror.do")
public String auth(Model model) {
return "auth/accesserror";
}
@GetMapping(value = "/auth/mylogin.do")
public String mylogin(Model model) {
return "auth/mylogin";
}
@GetMapping(value = "/auth/mylogout.do")
public String mylogout(Model model) {
return "auth/mylogout";
}
@GetMapping(value = "/auth/myinfo.do")
public String myinfo(Model model) {
return "auth/myinfo";
}
}
권한이 없는 페이지를 접근하면 403 페이지 대신에 보여줄 페이지를 만들도록 한다.
MemberController.java
package com.test.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.test.domain.AuthDTO;
import com.test.domain.MemberDTO;
import com.test.mapper.MemberMapper;
@Controller
public class MemberController {
@Autowired
private PasswordEncoder encoder;
@Autowired
private MemberMapper mapper; //mapper를 controller로 바로 받는 게 코드 관리에 좋은 상황은 아니지만 사용은 가능하다.
@GetMapping(value = "/auth/register.do")
public String register(Model model) {
return "auth/register";
}
@PostMapping(value = "/auth/registerok.do")
public String registerok(Model model, MemberDTO dto, int auth) {
//암호 인코딩
dto.setUserpw(encoder.encode(dto.getUserpw()));
//tblMember 추가
int result = mapper.add(dto);
if (auth >= 1) {
AuthDTO adto = new AuthDTO();
adto.setUserid(dto.getUserid());
adto.setAuth("ROLE_MEMBER");
mapper.addAuth(adto);
}
if (auth >= 2) {
AuthDTO adto = new AuthDTO();
adto.setUserid(dto.getUserid());
adto.setAuth("ROLE_ADMIN");
mapper.addAuth(adto);
}
return "redirect:/index.do";
}
}
회원 가입에 성공하면 index.do 메인 페이지로 redirect 한다.
현업에서는 컨트롤러가 실제로 DAO를 부르지 않는다. 또한, 컨트롤러에서 Mapper를 바로 부르는 것도 좋지 않다.
현업에 가면 Controller > DAO > SQL로 부르기에는 Controller가 너무 비대해지게 되기 때문에 여기서 하는 업무를 Controller > Service 클래스를 만들어서 위임한다.
업무를 분산시키기 위해서 Service 계층을 새로 만든다고 보면 된다.
- Controller > Mapper > SQL
- Controller > DAO > Mapper > SQL
- Controller > Service > DAO > Mapper > SQL
위와 같이 계층을 나누어 업무를 세분화하는 게 좋다.
CustomLoginSuccessHandler
package com.test.security;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
//로그인한 사람이 어떤 자격을 가지고 있는지 확인
//일반 회원 or 관리자
List<String> roleNames = new ArrayList<String>();
authentication.getAuthorities().forEach(authority -> {
roleNames.add(authority.getAuthority());
});
//System.out.println(roleNames);
if (roleNames.contains("ROLE_ADMIN")) {
response.sendRedirect("/spring/admin/admin.do");
} else if (roleNames.contains("ROLE_MEMBER")) {
response.sendRedirect("/spring/member/member.do");
} else {
response.sendRedirect("/spring/index.do");
}
}
}
AuthenticationSuccessHandler를 implements 해야 한다.
Authentication이 인증 정보를 가지고 있는 객체이다. 방금 로그인한 사람이 어떤 자격을 가지고 있는지 확인하도록 한다. 즉, 회원인지 관리자인지를 알아내는 작업을 한다.
getAuthorities 메서드는 로그인한 사람이 어떤 자격을 가지고 있는지 확인하는 메서드인데, 자격이 하나가 아닐 수 있으므로 forEach를 사용해 잠시 roleNames에 옮겨 둔다.
CustomUserDetailsService.java
package com.test.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import com.test.domain.CustomUser;
import com.test.domain.MemberDTO;
import com.test.mapper.MemberMapper;
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private MemberMapper mapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//DB 상에서 회원 정보를 인증 객체에 대입
//username == 아이디
MemberDTO dto = mapper.read(username);
//MemberDTO > 시큐리티에서 사용(변환) > CustomUser
return dto != null ? new CustomUser(dto) : null;
}
}
DB상에서 회원 정보를 읽어와서 인증 객체를 대입해주는 작업을 해 주도록 한다.
로그인을 성공하면 메서드가 자동으로 호출되어 방금 로그인한 사람의 아이디가 넘어오게 된다. 이를 가지고 여러가지 정보를 DB에서 세션으로 받아오는 작업을 한다.
MemberDTO.java
package com.test.domain;
import java.util.List;
import lombok.Data;
@Data
public class MemberDTO {
private String userid;
private String userpw;
private String username;
private String regdate;
private String enabled;
private List<AuthDTO> authlist;
}
AuthDTO.java
package com.test.domain;
import lombok.Data;
@Data
public class AuthDTO {
private String userid;
private String auth;
}
CustomUser.java
package com.test.domain;
import java.util.Collection;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import lombok.Getter;
@Getter
public class CustomUser extends User {
private MemberDTO dto;
public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
public CustomUser(MemberDTO dto) {
super(dto.getUserid(), dto.getUserpw(), dto.getAuthlist().stream().map(auth -> new SimpleGrantedAuthority(auth.getAuth())).collect(Collectors.toList()));
this.dto = dto;
}
}
CustomUser가 인증 객체가 되려면 User를 상속받아야 하고, 클래스에 @Getter를 붙여야 한다. 그리고 생성자를 2개 추가해 주어야 한다.
MemberTest.java
package com.test.spring;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.test.domain.AuthDTO;
import com.test.domain.MemberDTO;
import com.test.mapper.TestMapper;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({"file:src/main/webapp/WEB-INF/spring/root-context.xml", "file:src/main/webapp/WEB-INF/spring/security-context.xml"})
public class MemberTest {
@Autowired
private TestMapper mapper;
@Autowired
private PasswordEncoder encoder;
@Test
public void testInserMember() {
MemberDTO dto1 = new MemberDTO();
dto1.setUserid("Isaac");
dto1.setUsername("아이작");
dto1.setUserpw(encoder.encode("1111"));
mapper.add(dto1);
MemberDTO dto2 = new MemberDTO();
dto2.setUserid("Sopia");
dto2.setUsername("소피아");
dto2.setUserpw(encoder.encode("1111"));
mapper.add(dto2);
MemberDTO dto3 = new MemberDTO();
dto3.setUserid("Admin");
dto3.setUsername("관리자");
dto3.setUserpw(encoder.encode("1111"));
mapper.add(dto3);
}
@Test
public void testInsertAuth() {
AuthDTO dto1 = new AuthDTO();
dto1.setUserid("Isaac");
dto1.setAuth("ROLE_MEMBER");
mapper.addAuth(dto1);
AuthDTO dto2 = new AuthDTO();
dto2.setUserid("Sopia");
dto2.setAuth("ROLE_MEMBER");
mapper.addAuth(dto2);
AuthDTO dto3 = new AuthDTO();
dto3.setUserid("Admin");
dto3.setAuth("ROLE_MEMBER");
dto3.setAuth("ROLE_ADMIN");
mapper.addAuth(dto3);
}
}
@ContextConfiguration로 읽어야 할 xml 파일이 2개이다. 그래서 배열을 이용해 두 개의 문자열을 받는다.
TestMapper.java (I)
package com.test.mapper;
import com.test.domain.AuthDTO;
import com.test.domain.MemberDTO;
public interface TestMapper {
void add(MemberDTO dto);
void addAuth(AuthDTO dto);
}
TestMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.test.mapper.TestMapper">
<insert id="add" parameterType="com.test.domain.MemberDTO">
insert into tblMember (userid, userpw, username)
values (#{userid}, #{userpw}, #{username})
</insert>
<insert id="addAuth" parameterType="com.test.domain.AuthDTO">
insert into tblAuth (userid, auth)
values (#{userid}, #{auth})
</insert>
</mapper>
MemberMapper.java (I)
package com.test.mapper;
import com.test.domain.AuthDTO;
import com.test.domain.MemberDTO;
public interface MemberMapper {
int add(MemberDTO dto);
void addAuth(AuthDTO adto);
MemberDTO read(String username);
}
MemberMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.test.mapper.MemberMapper">
<insert id="add" parameterType="com.test.domain.MemberDTO">
insert into tblMember (userid, userpw, username)
values (#{userid}, #{userpw}, #{username})
</insert>
<insert id="addAuth" parameterType="com.test.domain.AuthDTO">
insert into tblAuth (userid, auth)
values (#{userid}, #{auth})
</insert>
<resultMap type="com.test.domain.MemberDTO" id="memberMap">
<id property="userid" column="userid"/>
<result property="userpw" column="userpw"/>
<result property="username" column="username"/>
<result property="enabled" column="enabled"/>
<result property="regdate" column="regdate"/>
<collection property="authlist" resultMap="authMap"></collection>
</resultMap>
<resultMap type="com.test.domain.AuthDTO" id="authMap">
<result property="userid" column="userid"/>
<result property="auth" column="auth"/>
</resultMap>
<select id="read" resultMap="memberMap">
select
m.userid,
m.userpw,
m.username,
m.enabled,
m.regdate,
a.auth
from tblMember m
left outer join tblAuth a
on m.userid = a.userid
where m.userid = #{userid}
</select>
</mapper>
resultMap은 우리가 직접 정의해야 한다.
type에는 DTO를 적고, id는 자유롭게 작성한다. id 태그의 property는 기본키를 의미하며, column은 서로 연결해주는 매핑 정보를 의미한다. 기본키 컬럼과 일반 컬럼을 구분하여 연결해주면 된다. 이렇게 받은 데이터는 MemberDTO로 바로 받을 수 없다. Spring Security에서 사용하려면 CustomUser라는 것으로 변환시켜야 한다. 이때 CustomUser를 사용한다.
이는 Spring Security에서 필수적인 행동이 아니며, MyBatis에서 하는 행동을 추가로 한 것이다.
index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- index.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Index Page <small>모든 사용자</small></h2>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
member.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- member.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Member Page <small>회원 + 관리자</small></h2>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
admin.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- admin.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Administrator Page <small>관리자</small></h2>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
accesserror.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- accesserror.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Access Denied Page</h2>
<div>접근 권한이 없습니다.</div>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
mylogin.jsp
주의사항
- method="POST"
- action="/컨텍스트명/login"
- 아이디: name="username"
- 암호: name="password"
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- mylogin.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Logging Page</h2>
<!--
*** 주의
1. method="POST"
2. action="/컨텍스트명/login"
3. 아이디: name="username"
4. 암호: name="password"
-->
<form method="POST" action="/spring/login">
<div>
<input type="text" name="username" placeholder="ID" required>
</div>
<div>
<input type="password" name="password" placeholder="Password" required>
</div>
<div>
<button class="in">로그인</button>
</div>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
</form>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
action을 우리가 만들지 않고, 이미 만들어져 있는 것을 사용해야 한다.
서브 처리가 우리가 하는 게 아니므로 id의 name은 반드시 약속된 이름으로 username이어야 한다.
CSRF (Cross-site request forgery)
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
CSRF는 일종의 해킹 기법 중에 하나로, 요청 정보를 다른 사이트에서 보내서 위조하는 기법이다.
다른 사람이 만든 페이지에서 데이터를 속여서 문제가 되는 행동을 하는데, 스프링 시큐리티가 CSRF를 방지하는 기능이 내장되어 있다. 그래서 모든 POST 요청을 할 때에는 반드시 위조가 되지 않았다는 사실을 증명해야 한다.
로그인을 하면 이 값도 서버에 저장하는데, 서버에 이 값을 보낸다. 이처럼 인증 토큰이 똑같은 경우에만 내가 만든 로그인 페이지에서 Id, Password가 넘어왔다는 의미가 되므로, 위조되지 않았다는 사실을 증명하는 셈이다.
mylogout.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- mylogout.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Logging Page</h2>
<form method="POST" action="/spring/auth/mylogout.do">
<div>
<button class="out">로그아웃</button>
</div>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
</form>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
action은 로그아웃 페이지를 작성한다. 이는 재귀호출처럼 보이지만, 재귀호출이 아니고 내부적으로 호출이 되는 것이다.
register.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- register.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>Register Page <small>가입</small></h2>
<form method="POST" action="/spring/auth/registerok.do">
<table class="vertical">
<tr>
<th>아이디</th>
<td><input type="text" name="userid" required></td>
</tr>
<tr>
<th>암호</th>
<td><input type="password" name="userpw" required></td>
</tr>
<tr>
<th>이름</th>
<td><input type="text" name="username" required></td>
</tr>
<!-- 테스트를 위한 권한 생성 -->
<tr>
<th>권한</th>
<td>
<select name="auth">
<option value="1">회원</option>
<option value="2">관리자</option>
</select>
</td>
</tr>
</table>
<div>
<input type="submit" value="가입하기">
</div>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
</form>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
모든 POST 요청에는 _csrf.token를 보내야 한다. 그렇지 않으면 위와 같은 에러가 발생한다.
myinfo.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://me2.do/5BvBFJ57">
<style>
</style>
</head>
<body>
<!-- myinfo.jsp -->
<%@ include file="/WEB-INF/views/inc/header.jsp" %>
<h2>MyInfo page <small>내 정보</small></h2>
<!-- property="principal" -> CustomUser 객체 -->
<div class="message" title="principal">
<sec:authentication property="principal"/>
</div>
<div class="message" title="MemberDTO">
<sec:authentication property="principal.dto"/>
</div>
<div class="message" title="사용자 이름">
<sec:authentication property="principal.dto.username"/>
</div>
<div class="message" title="사용자 아이디">
<sec:authentication property="principal.dto.userid"/>
<sec:authentication property="principal.username"/>
</div>
<div class="message" title="사용자 권한">
<sec:authentication property="principal.dto.authlist"/>
</div>
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script>
</script>
</body>
</html>
property로 principal이 있다. 이게 바로 CustomUser 객체이다.
header.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
<!-- inc > header.jsp -->
<header>
<h1>Spring Security</h1>
<ul>
<li><a href="/spring/index.do">Index</a></li>
<sec:authorize access="hasRole('ROLE_MEMBER')">
<li><a href="/spring/member/member.do">Member</a></li>
</sec:authorize>
<sec:authorize access="hasRole('ROLE_ADMIN')">
<li><a href="/spring/admin/admin.do">Admin</a></li>
</sec:authorize>
<sec:authorize access="isAnonymous()">
<li><a href="/spring/auth/mylogin.do">Login</a></li>
<li><a href="/spring/auth/register.do">Register</a></li>
</sec:authorize>
<sec:authorize access="isAuthenticated()">
<li><a href="/spring/auth/mylogout.do">Logout</a></li>
<li><a href="/spring/auth/myinfo.do">Info</a></li>
</sec:authorize>
</ul>
</header>
스프링이 지원하는 사용자 정의 태그를 사용하기 위해 taglib를 추가해 주었다.
아무런 권한도 없는 익명 사용자를 위한 isAnonymous 함수와 정 반대의 조건인 isAuthenticated 함수가 존재한다.
script.sql
-- SecurityTest > script.sql
drop table tblMember;
drop table tblAuth;
select * from tblMember;
select * from tblAuth;
-- 회원 테이블
create table tblMember(
userid varchar2(50) not null primary key,
userpw varchar2(100) not null, --최소 100바이트 이상(암호화 인코딩을 시켜야 해서)
username varchar2(100) not null,
regdate date default sysdate not null,
enabled char(1) default '1'
);
-- 권한 테이블
create table tblAuth (
userid varchar2(50) not null,
auth varchar2(50) not null,
constraint fk_member_auth foreign key(userid) references tblMember(userid)
);
insert into tblAuth values('Admin', 'ROLE_MEMBER');
commit;
select
m.userid,
m.userpw,
m.username,
m.enabled,
m.regdate,
a.auth
from tblMember m
left outer join tblAuth a
on m.userid = a.userid
where m.userid = 'Isaac';
암호화 인코딩을 넣기 위해서 userpw는 최소 100바이트 이상 주어야 한다. 그래야 DB를 해석하지 못하게끔 할 수 있다.
그리고 회원 테이블 외에도 권한(자격) 테이블을 만들어 주었다.
권한 테이블은 로그인한 사람이 ROLE_MEMBER, ROLE_ADMIN 중 어떤 권한을 가지는지를 저장한다.
일반 회원은 ROLE_MEMBER만 가지면 되는데, 관리자 계정은 두 개를 가져야 하기 때문에 1:N 관계가 된다. 따라서 레코드 하나로 표현할 수 없으므로 테이블로 만든 것이다.