라즈베리파이반

라즈베리파이 등 컴퓨터계열 게시판입니다.

제목스프링 레거시로 게시판 만들기 (13) : 시큐리티 적용2022-11-13 01:50
작성자user icon Level 4
첨부파일security.zip (4.6KB)

88x31.png


지난번 글에서 작성한 것처럼 로그인 인증(Authentication)과 권한 인가(Authorization) 로직은 서블릿 필터나 스프링 인터셉터를 통해서 구현할 수 있습니다.


스프링은 인증과 인가 등의 애플리케이션 보안과 관련된 기능들을 모은 스프링 시큐리티(Spring Security) 프로젝트를 제공합니다.


스프링 시큐리티의 기본 아키텍처는 다음과 같습니다.


 


스프링 시큐리티는 서블릿 필터를 기반으로 합니다. 서블릿 필터는 스프링 컨테이너에서 생성된 빈이 아니기 때문에 스프링 빈을 주입받을 수 없습니다. 그렇기때문에 스프링 시큐리티에서는 서블릿 필터에 프록시를 등록하고, 해당 프록시를 통해 스프링 컨테이너에서 서블릿을 처리할 수 있도록 해줍니다.


이번 글에서는 스프링 시큐리티를 통해 인증 및 인가 기능을 구현해보도록 하겠습니다.



1. 의존성 추가


우선 메이븐 레포지토리를 참고하여 spring-security-webspring-security-config를 의존성 추가합니다. 글 작성 기준으로 가장 사용률이 높은 5.7.3버전을 추가하겠습니다.


pom.xml 

...

        <!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-web -->

        <dependency>

            <groupId>org.springframework.security</groupId>

            <artifactId>spring-security-web</artifactId>

            <version>5.7.3</version>

        </dependency>

        

        <!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-config -->

        <dependency>

            <groupId>org.springframework.security</groupId>

            <artifactId>spring-security-config</artifactId>

            <version>5.7.3</version>

        </dependency>

...



2. 필터 추가


다음으로 서블릿 필터를 추가하도록 하겠습니다. 스프링 시큐리티에서 제공하는 서블릿 필터는 DelegatingFilterProxy 입니다. 지난번에 등록한 loginCheckFilter를 제거하고 해당 필터를 필터체인에 등록합니다.


/src/main/webapp/WEB-INF/web.xml

...

    <!-- 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>

...

    <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>

...


springSecurityFilterChain이라는 이름으로 org.springframework.web.filter.DelegatingFilterProxy 클래스를 필터로 등록하고, 전체 경로에 매핑해줍니다. 이 경우 클라이언트의 모든 요청이 해당 필터를 거치게 됩니다.


DelegatingFilterProxy는 서블릿 필터이므로 스프링 컨테이너의 제어를 받지 않습니다. 대신에 스프링에서 지원하는 특수 필터인 FilterChainProxy를 감싸고 있어서 해당 필터가 하는 일을 대신하게 됩니다. FilterChainProxy는 스프링 빈이므로, 스프링 컨테이너의 제어를 받아서 의존성 빈을 주입할 수 있습니다. 시큐리티에서는 springSecurityFilterChain이라는 이름의 객체가 FilterChainProxy를 구현하여 스프링 빈으로 등록되어 있으며, DelegatingFilterProxy가 해당 빈을 대리자로 등록하기 위해서는 필터이름을 빈의 이름과 동일하게 설정하면 됩니다. 이러한 이유로 filter-namespringSecurityFilterChain로 등록합니다.



3. 시큐리티 필터


springSecurityFilterChain은 많은 필터 리스트로 구성되어 체인을 형성하며, 순서대로 실행됩니다. 일부 필터의 기능은 다음과 같습니다.

 필터

 기능

 WebAsyncManagerIntegrationFilter

 비동기 기능을 사용할때 시큐리티 컨텍스트를 사용할 수 있도록 해주는 필터

 SecurityContextPersistenceFilter

 Authentication 객체를 보관하는 시큐리티 컨텍스트를 생성하는 필터

 HeaderWriterFilter

 응답(Response)에 시큐리티와 관련된 헤더를 설정하는 필터

 CorsFilter

 허가된 사이트나 클라이언트의 요청인지 검사하는 필터

 CsrfFilter

 CSRF 공격을 방어하는 필터

 LogoutFilter

 로그아웃 요청을 처리하는 필터

 UsernamePasswordAuthenticationFilter

 username, password를 사용하는 form 기반 인증을 처리하는 필터

 ConcurrentSessionFilter

 동시세션을 제어하는 필터

 BearerTokenAuthenticationFilter

 JWT 인증 필터

 BasicAuthenticationFilter

 Basic 인증 필터

 RequestCacheAwareFilter

 캐시요청을 처리하는 필터

 SecurityContextHolderAwareRequestFilter

 보안 관련 Servlet 3 스펙을 지원하기 위한 필터

 RememberMeAuthenticationFilter

 RememberMe 쿠키를 검사하여 인증하는 필터

 AnonymousAuthenticationFilter

 익명사용자의 요청을 처리하는 필터

 SessionManagementFilter

 세션을 제어하는 필터

 ExcpetionTranslationFilter

 예외 처리 필터

 FilterSecurityInterceptor

 인가를 결정하는 필터



4. 시큐리티 컨텍스트


시큐리티 컨텍스트는 단독으로 설정되어야 하므로 root-context.xml과는 별도의 xml 파일을 작성해야합니다. security-context.xml 파일을 새로 만들고 시큐리티 컨텍스트를 설정하겠습니다. 해당 파일을 스프링 컨테이너의 설정파일로 등록해야 하므로 위에서 작성한 web.xml 파일에는 contextConfigLocation의 파라미터 값으로 /WEB-INF/spring/security-context.xml을 추가했습니다.


이제 각종 필터들을 통해 체인을 설정해야하는데 필터의 종류가 많고 복잡하기때문에 기회가 되면 시큐리티에 대해서 글을 따로 작성해보도록 하겠습니다. 이 글에서는 인증에 사용되는 필터체인을 구성해보겠습니다.


1) 아키텍처


인증의 아키텍처는 다음과 같습니다.


① 클라이언트의 요청(Request)이 오면 AuthenticationFilter를 거칩니다.

② 유저자격을 기반으로 인증토큰(AuthenticationToken) 객체를 생성합니다.

③ 인증 필터를 통해 AuthenticationToken 객체를 AuthenticationManager 인터페이스에 위임합니다. ProviderManager는 AuthenticationManager의 구현체입니다.

④ AuthenticatioProvider를 통해 인증을 시도합니다.

⑤ UserDetailsService를 통해 username을 기반으로 userDetails를 검색합니다.

⑥ UserDetails를 통해 User 객체를 검색합니다.

⑦ UserDetails가 User 객체를 UserDetailsService에 전달합니다.

⑧ 인증에 성공하면 인증정보를 리턴하고, 실패하면 AuthenticationException을 던집니다.

⑨ AuthenticationManager는 완전한 인증 객체를 AuthenticationFilter에 반환합니다.

⑩ SecuriryContext에 인증 객체를 저장합니다.


2) 설정파일 작성


아래와 같이 시큐리티 컨텍스트의 설정파일을 작성합니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http auto-config="true" use-expressions="false">

        <intercept-url pattern="/board/**" access="ROLE_USER"/>

    </http>


    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


네임스페이스를 security로 설정하여 security 스키마를 기본 태그로 사용합니다.


http 태그는 경로를 매핑하여 필터체인의 실행여부를 설정합니다. auto-config 속성을 true로 설정하면 필터체인의 default 값이 설정됩니다. use-expressions 속성은 spEL 문법의 사용여부를 설정하는데 지금은 필요없으므로 false로 설정합니다.


하위 태그로 intercept-url을 입력했는데, 예상하듯이 pattern에 해당하는 경로에 대한 인터셉터 역할을 합니다. access 속성은 인가권한을 나타냅니다.


authentication-manager 태그는 AuthenticationManager를 설정합니다. 하위태그인 authentication-provider AuthenticationProvider를 설정합니다. UsernamePasswordAuthenticationFilter에서는 기본 프로바이더로 DaoAuthenticationProvider를 사용합니다. ref 속성을 통해 커스텀 프로바이더를 설정할 수도 있습니다.


다음으로 UserDetailsService를 설정해야하는데, 위와 같이 user-service 태그를 설정하여 인메모리 방식으로 UserDetails를 가져올 수 있습니다. 나중에는 DB를 통해 인증을 할 예정이지만, 지금은 간단하게 테스트하기 위해 인메모리 방식으로 adminuser라는 아이디를 통해 인증을 하도록 하겠습니다.


스프링 시큐리티 4버전 이후로는 비밀번호를 암호화해야합니다. 하지만 user 태그의 password 속성에서 앞에 {noop}를 추가하면 암호화 과정을 생략하고 로그인을 테스트할 수 있습니다.




저장후 톰캣을 재시작하고 localhost:8080/board에 접속하면 다음과 같이 스프링 시큐리티에서 제공하는 기본 로그인화면이 출력되는 것을 확인할 수 있습니다.


 


admin 계정으로 로그인하면 403 에러가 발생하는 것을 확인할 수 있습니다.


 


이유는 /board/** 경로에 대한 access 권한을 ROLE_USER로 설정했기 때문에 ROLE_ADMIN 권한을 가진 admin 계정은 해당 경로에 대한 인가를 받지 못했기 때문입니다.


user 계정으로 접속하면 정상적으로 게시판이 출력되는 것을 확인할 수 있습니다. 로그아웃을 하려면 localhost:8080/logout에 접속하면 됩니다.


 


3) 권한 설정


access 권한은 spEL 문법을 사용하여 설정할 수도 있습니다.

 SPEL

 설명

 hasRole(‘ROLE_USER’)

 ROLE_USER 권한을 가진 유저만 접근 가능

 hasAnyRole(‘ROLE_USER’, ‘ROLE_ADMIN’)

 ROLE_USER 또는 ROLE_ADMIN 권한을 가진 유저만 접근 가능

 permitAll

 모두 접근 가능

 denyAll

 모두 접근 불가

 isAnonymous()

 인증하지 않은 유저만 접근 가능

 isRememberMe()

 자동로그인 기능을 사용한 유저만 접근 가능

 isAuthenticated()

 인증한 유저만 접근 가능

 isFullyAuthenticated()

 인증을 하고, 자동 로그인 기능을 사용하지 않은 유저만 접근 가능


아래 코드와 같이 use-expressions 속성을 true로 설정하고 /board/** 경로에 대한 access를 isAuthenticated()로 설정하세요.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http auto-config="true" use-expressions="true">

        <intercept-url pattern="/board/**" access="isAuthenticated()"/>

    </http>


    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>



4) 인증 설정


다음과 같이 http 태그의 security 속성에 none 값을 설정하여 인증 예외 경로를 설정함으로써 인증을 하지 않을 수도 있습니다.


이때 체인은 위에서 아래로 진행되기 때문에 인증 예외 경로를 제외한 나머지 경로는 가장 아래 필터가 적용됩니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http pattern="/" security="none"></http>

    <http pattern="/login" security="none"></http>

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/*/**" access="isAuthenticated()"/>

    </http>

    

    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


basic authentication을 사용하려면 <http-basic /> 태그를 추가하면 됩니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http pattern="/" security="none"></http>

    <http pattern="/login" security="none"></http>

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/*/**" access="isAuthenticated()"/>

        <http-basic />

    </http>

    

    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


5) 로그인 페이지


시큐리티에서 제공하는 기본 로그인 페이지가 아니라 사용자가 구현한 로그인 페이지를 사용하려면 form-login 태그를 추가하면 됩니다. 해당 태그에도 다양한 속성이 존재합니다.

 속성

 설명

 username-parameter

 username에 대한 파라미터명 설정 (기본값: username)

 password-parameter

 password에 대한 파라미터명 설정 (기본값: password)

 login-page

 로그인 페이지 url 설정

 login-processing-url

 로그인 처리 url 설정 (기본값: /login)

 default-target-url

 로그인 성공시 이동할 url 설정

 authentication-failure-url

 로그인 실패시 이동할 url 설정 (기본값: /login?error=1)

 authentication-success-handler-ref

 로그인 성공시 호출할 핸들러 이름 설정

 authentication-failure-handler-ref

 로그인 실패시 호출할 핸들러 이름 설정

 always-use-default-target

 로그인 성공시 항상 default-target-url로 이동할지 설정


표를 참고하여 로그인폼을 설정하겠습니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http pattern="/" security="none"></http>

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/board/**" access="isAuthenticated()"/>

        <form-login

            login-page="/login"

            login-processing-url="/login"

            username-parameter="email"

            password-parameter="password" />

    </http>

    

    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin@test.com" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user@test.com" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


지난번에 추가한 로그인폼을 사용하겠습니다. username이 이메일 형식이므로 user 태그의 name을 이메일형식으로 변경했습니다.


LoginController를 수정해줍시다.


/kro/rubisco/controller/LoginController.java

package kro.rubisco.controller;


import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpSession;


import org.springframework.stereotype.Controller;

import org.springframework.web.bind.annotation.GetMapping;


import lombok.RequiredArgsConstructor;


@Controller

@RequiredArgsConstructor

public class LoginController {

    

    @GetMapping("/login")

    public void getLoginView() {}

    

    @GetMapping("/logout")

    public String logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if (session != null) {

            session.invalidate();

        }

        return "redirect:/";

    }

}


스프링에서 제공하는 로그인 로직을 사용하므로 /login 경로로 PostMapping되던 login 메소드를 제거했습니다.


로그인 뷰에 csrf 토큰도 설정해야합니다. 폼태그 아래 다음 input 태그를 넣어주세요.


/src/main/webapp/WEB-INF/views/login.jsp

...

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

...


6) 로그아웃 설정


시큐리티에서는 로그인 뿐만 아니라 로그아웃 기능 역시 제공합니다. 설정을 위해서는 logout 태그를 추가하며, 다음과 같은 속성을 설정할 수 있습니다.

 속성

 설명

 logout-url

 로그아웃 페이지 url 설정

 logout-success-url

 로그아웃 성공시 이동할 url 설정 (기본값: /login?logout)

 invalidate-session

 로그아웃 성공시 세션의 연결을 끊을지 설정 (기본값: true)

 delete-cookie

 로그아웃 성공시 삭제할 쿠키 이름 설정

 success-handler-ref

 로그아웃 성공시 호출할 핸들러 이름 설정


표를 참고하여 로그아웃폼도 설정합니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <http pattern="/" security="none"></http>

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/board/**" access="isAuthenticated()"/>

        <form-login

            login-page="/login"

            login-processing-url="/login"

            username-parameter="email"

            password-parameter="password" />

            

        <logout

            logout-url="/logout"

            logout-success-url="/"

            delete-cookies="JSESSIONID" />

    </http>

    

    <authentication-manager>

        <authentication-provider>

            <user-service>

                <user name="admin@test.com" password="{noop}admin" authorities="ROLE_ADMIN" />

                <user name="user@test.com" password="{noop}user" authorities="ROLE_USER" />

            </user-service>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


컨트롤러에서 logout 메소드를 지워주세요.


/kro/rubisco/controller/LoginController.java

package kro.rubisco.controller;


import org.springframework.stereotype.Controller;

import org.springframework.web.bind.annotation.GetMapping;


@Controller

public class LoginController {

    

    @GetMapping("/login")

    public void getLoginView() {}

}


csrf의 disabled 기본값이 false 이므로 로그아웃 요청도 post로 해야하고 csrf 토큰도 설정해야합니다. getBoardList.jsp에 로그아웃 폼을 추가해주세요.


/src/main/webapp/WEB-INF/views/board/getBoardList.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>

<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>


<!DOCTYPE html>

<html lang="ko">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>

    <%@ include file="./include/tailwindcss.jsp" %>

    <title>게시글 목록</title>

</head>

<body>

<main>

    <h1>게시글 목록</h1>

    <form method="post" action="/logout">

        <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

        <input class="flex mr-auto mb-3 text-white text-sm sm:text-base bg-rose-500 border-0 py-1 px-3 

            sm:py-2 sm:px-6 focus:outline-none hover:bg-rose-600 rounded cursor-pointer"

            type="submit" value="로그아웃" />

    </form>

    <article>

    <table>

        <thead>

        <tr>

            <th></th>

                <th scope="col">카테고리</th>

                <th scope="col" class="w-full">제목</th>

                <th scope="col">작성자</th>

                <th scope="col">등록일</th>

                <th scope="col">조회수</th>

        </tr>

            </thead>

            <tbody>

            <c:forEach items="${pageInfo.contentList}" var="board" step="1" varStatus="status">

        <tr>

            <td>${pageInfo.totalSize - pageInfo.first - status.index + 1}</td>

            <td>${board.category.category}</td>

            <td><a href="/board/${board.documentId}">${board.title}</a></td>

            <td>${board.member.nickName}</td>

            <td><fmt:formatDate value="${board.createDate}" pattern="yyyy-MM-dd"/></td>

            <td>${board.readCount}</td>

        </tr>

        </c:forEach>

        </tbody>

    </table>

    </article>

    <div class="flex mt-2 sm:mt-4 lg:w-2/3 w-full mx-auto">

    <button class="write" onClick="window.location='/board?act=write'">쓰기</button>

    </div>

    <%@ include file="./include/pageNav.jsp" %>

</main>

</body>

</html>


로그인후 상단에 로그아웃을 클릭하면 로그아웃 처리가 되고 메인페이지로 이동하는 것을 확인할 수 있습니다.


7) DB 연결


이번에는 인메모리 방식의 로그인이 아니라 DB를 조회하여 로그인 처리를 해보겠습니다.


authentication-provider 태그 아래 jdbc-user-service 태그를 추가합니다. 해당태그에서 data-source-ref, users-by-username-query, authorities-by-username-query 속성을 설정해야합니다.


data-source-ref 속성은 DB의 데이터소스의 id를 입력합니다. 데이터소스는 root-context.xml 파일에 dataSource라는 id로 빈을 생성했습니다.


users-by-username-query 속성은 유저의 정보를 가져오는 쿼리를 설정합니다. username, password, enabled를 하나의 튜플로 하여 유저정보를 가져옵니다.


authorities-by-username-query 속성은 유저의 권한을 가져오는 쿼리를 설정합니다. username, authority를 하나의 튜플로 하여 권한정보를 가져옵니다.


password를 암호화하는 인코더를 추가할 필요도 있습니다. 스프링에서 제공하는 BCryptPasswordEncoder를 빈으로 추가하고 authentication-provider 태그 아래 password-encoder 태그를 추가합니다. 해당 태그의 ref 속성값으로 인코더 이름을 입력합니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <beans:bean id ="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

    

    <http pattern="/" security="none"></http>

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

    <intercept-url pattern="/board/**" access="isAuthenticated()"/>

    <form-login

        login-page="/login"

        login-processing-url="/login"

        username-parameter="email"

        password-parameter="password" />

        

    <logout

        logout-url="/logout"

        logout-success-url="/"

        delete-cookies="JSESSIONID" />

    </http>

    

    <authentication-manager>

        <authentication-provider>

            <jdbc-user-service 

                data-source-ref="dataSource" 

                users-by-username-query="select email, password, 1 enabled from member where email=?"

                authorities-by-username-query="select m.email, g.group_name from member m join member_group g on m.group_id = g.group_id where m.email=?" />

            <password-encoder ref="bcryptPasswordEncoder"/>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


암호화된 비밀번호를 비교하기때문에 현재 DB에 저장되어 있는 계정으로는 로그인할 수 없습니다. 새로운 계정을 만들도록 하겠습니다. 그전에 우선 MemberServiceImpl 객체에서 regist 메소드를 수정하도록 하겠습니다.


/kro/rubisco/service/impl/MemberServiceImpl.java

package kro.rubisco.service.impl;


import java.util.List;


import org.apache.ibatis.session.SqlSession;

import org.springframework.security.crypto.password.PasswordEncoder;

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;


import kro.rubisco.dao.MemberDAO;

import kro.rubisco.dto.LoginDTO;

import kro.rubisco.dto.MemberDTO;

import kro.rubisco.service.MemberService;


@Service

@Transactional(readOnly = true)

public class MemberServiceImpl implements MemberService {


    private final MemberDAO memberDAO;

    private final PasswordEncoder bcryptPasswordEncoder;


    public MemberServiceImpl(SqlSession sqlSession, PasswordEncoder bcryptPasswordEncoder) {

        this.memberDAO = sqlSession.getMapper(MemberDAO.class);

        this.bcryptPasswordEncoder = bcryptPasswordEncoder;

    }

    

    @Override

    public void regist(MemberDTO member) throws Exception {

        member.setPassword(bcryptPasswordEncoder.encode(member.getPassword()));

        memberDAO.create(member);

    }


    @Override

    public MemberDTO read(Long memberId) throws Exception {

        return memberDAO.read(memberId);

    }

    

    @Override

    public MemberDTO read(String email) throws Exception {

        return memberDAO.getMemberByEmail(email);

    }


    @Override

    public void modify(MemberDTO member) throws Exception {

        memberDAO.update(member);

    }


    @Override

    public void remove(Long memberId) throws Exception {

        memberDAO.delete(memberId);

    }


    @Override

    public List<MemberDTO> listAll() throws Exception {

        return memberDAO.listAll();

    }


    @Override

    public MemberDTO login(LoginDTO loginForm) throws Exception {

        return memberDAO.login(loginForm);

    }


}


bcryptPasswordEncoder를 생성자 주입하고 계정을 등록할 때 password를 암호화하여 등록하도록 수정했습니다.


회원가입폼에 scrf 토큰도 입력해주고 톰캣을 재부팅합니다.


/src/main/webapp/WEB-INF/views/member/signUpForm.jsp

...

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

...


이제 새로 계정을 만들어 로그인하면 됩니다.


8) 시큐리티 컨텍스트 사용


스프링 시큐리티를 통해 로그인하면 JSP에서 security taglib을 사용하여 로그인 인증을 확인하거나 세션 정보를 활용할 수 있습니다.


우선 spring-security-taglibs를 의존성 추가합니다.


pom.xml

...

    <!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-taglibs -->

    <dependency>

        <groupId>org.springframework.security</groupId>

        <artifactId>spring-security-taglibs</artifactId>

        <version>5.7.3</version>

    </dependency>

...


jsp 파일에 다음 태그립을 추가함하면 해당 시큐리티 태그를 사용할 수 있습니다. 메인페이지에 인증을 확인하고 세션에서 사용자 정보를 가져와보겠습니다. 그전에 security-context.xml 파일에서 메인페이지 경로에서 시큐리티를 사용하도록 해당 코드를 제거합니다.


/src/main/webapp/WEB-INF/spring/security-context.xml

<http pattern="/" security="none"></http> 


메인 페이지 뷰를 다음과 같이 수정합니다.


/src/main/webapp/WEB-INF/views/board/getBoardList.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>

<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>


<html>

<head>

    <title>Home</title>

</head>

<body>

<h1>

    Hello world!  

</h1>


<sec:authorize access="isAuthenticated()">

    <sec:authentication property="principal" var="principal" />

    <P>${principal}</P>

    <form action="<c:url value='/logout' />" method="post">

        <sec:csrfInput/>

        <button type="submit">로그아웃</button>

    </form>

</sec:authorize>

</body>

</html>


헤더에 시큐리티 taglib을 추가하면 authorize 태그를 통해 인증여부를 확인할 수 있습니다. access 속성은 spEL 문법을 사용하며, 위에서 설명한 내용과 같습니다.


authentication 태그를 통해서는 유저정보를 담은 principal 객체를 가져올 수 있습니다.



해당 principal 객체는 시큐리티에서 제공하는 UserDetails 인터페이스의 구현체입니다. 시큐리티에서 기본적으로 제공하는 principal 객체로는 제한적인 정보만 가져올 수 있습니다. 아이디, 패스워드, 권한 이외에 다른 정보를 가져오고 싶다면 UserDetailsServices 인터페이스를 구현하여 참조하는 방식을 사용하면 됩니다.


9) UserDetailsService 인터페이스 구현


우선 UserDetails 인터페이스를 구현해야합니다. 모든 기능을 구현하지 않고 시큐리티에서 구현한 User 객체를 상속받고 MemberDTO를 멤버변수로 추가하겠습니다.


/kro/rubisco/auth/CustomUser.java

package kro.rubisco.auth;


import java.util.ArrayList;

import java.util.Arrays;


import org.springframework.security.core.GrantedAuthority;

import org.springframework.security.core.authority.SimpleGrantedAuthority;

import org.springframework.security.core.userdetails.User;


import kro.rubisco.dto.MemberDTO;

import lombok.Getter;



public class CustomUser extends User {

    

    @Getter

    private MemberDTO member;

    

    public CustomUser(MemberDTO member) {

        super(

            member.getEmail(), 

            member.getPassword(), 

            new ArrayList<GrantedAuthority>(

                Arrays.asList(

                    new SimpleGrantedAuthority(member.getGroup().getGroupName())

                )

            )

        );

        this.member = member;

    }

}


다음으로 UserDetailsService 인터페이스를 구현합니다. username을 매개변수로 받는 loadUserByUsername 메소드를 구현하면 됩니다. 해당 메소드는 UserDetails 객체를 반환합니다. 그러므로 username을 통해 DB에서 유저정보를 가져오고, 해당 정보를 CustomUser에 주입하여 으로써 시큐리티 컨텍스트로 member 객체를 전달할 수 있습니다.


/kro/rubisco/auth/CustomUserDetailsService.java

package kro.rubisco.auth;


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 kro.rubisco.dto.MemberDTO;

import kro.rubisco.service.MemberService;

import lombok.Setter;


public class CustomUserDetailsService implements UserDetailsService {


    @Setter(onMethod_ = @Autowired)

    private MemberService memberService;

    

    @Override

    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        try {

            MemberDTO member = memberService.read(email);

            return member == null ? null : new CustomUser(member);

        } catch (Exception e) {

            e.printStackTrace();

            return null;

        } 

    }

}


@Setter를 통해 memberService의 setter를 생성하고 해당 setter가 @Autowired를 통해 memberService를 setter주입하도록 선언합니다. 주입받은 memberService를 통해 member 객체를 가져올 수 있으며, 해당 객체를 주입하여 CustomUser 객체를 생성할 수 있습니다. 이 객체는 UserDetails 인터페이스의 구현체이므로 자동으로 캐스팅되어 리턴이 가능합니다.


이제 시큐리티 컨텍스트를 수정해주겠습니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <beans:bean id ="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder" />

    <beans:bean id="customUserDetailsService" class="kro.rubisco.auth.CustomUserDetailsService" />

    

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/board/**" access="isAuthenticated()"/>

        <form-login

            login-page="/login"

            login-processing-url="/login"

            username-parameter="email"

            password-parameter="password" />

            

        <logout

            logout-url="/logout"

            logout-success-url="/"

            delete-cookies="JSESSIONID" />

    </http>

    

    <authentication-manager>

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

            <password-encoder ref="bcryptPasswordEncoder"/>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


customUserDetailsService라는 이름으로 빈을 설정하여 authentication-provideruser-service-ref 속성값으로 입력해줍니다.


JSP에서 member 객체에 접근하려면 principal.member로 접근하면 됩니다.


/src/main/webapp/WEB-INF/views/home.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>

<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>


<html>

<head>

    <title>Home</title>

</head>

<body>

<h1>

    Hello world!  

</h1>


<sec:authorize access="isAuthenticated()">

    <sec:authentication property="principal.member" var="member" />

    <P>${member.nickName}님, 환영합니다.</P>

    <form action="<c:url value='/logout' />" method="post">

        <sec:csrfInput/>

        <button type="submit">로그아웃</button>

    </form>

</sec:authorize>

</body>

</html>


10) remember-me 구현


마지막으로 로그인상태를 유지하는 remember-me 기능을 구현해보겠습니다.


remember-me는 토큰을 생성하여 DB에 저장하고, 클라이언트의 쿠키를 통해 이를 검증하는 방식으로 진행됩니다.


기능구현을 위해서는 RememberMeAuthenticationFilter, TokenBasedRememberMeServices, RememberMeAuthenticationProvider의 구현이 필요하며, 시큐리티는 해당 구현체를 기본적으로 제공하고 있습니다.


RememberMeAuthenticationFilter는 클라이언트 요청에서 remember-me 쿠키가 있는지 확인하고, TokenBasedRememberMeServicesPersistentTokenRepository를 통해 토큰을 조회하여 RememberMeAuthenticationProvider를 통해 인증을 수행합니다.




기능구현을 위해서는 우선 DBeaver를 통해 테이블 생성이 필요합니다.

CREATE TABLE persistent_logins (

    username varchar(64) not null,

    series varchar(64) not null,

    token varchar(64) not null,

    last_used timestamp not null,

    PRIMARY KEY (series)

);


해당 테이블에서 PersistentTokenRepository를 통해 토큰을 조회합니다.


이제 시큐리티 컨텍스트에서 remember-me 태그를 통해 RememberMeAuthenticationFilter를 설정하면 됩니다.


remember-me-cookie 속성으로 쿠키이름을 설정할 수 있고, remember-me-parameter 속성으로 파라미터명을 설정할 수 있습니다.


token-repository-ref 속성은 PersistentTokenRepository 구현체를, services-ref 속성은 RememberMeServices 구현체를 설정합니다.


token-validity-seconds는 쿠키가 지속되는 시간을, data-source-refDataSource를 설정합니다.


/src/main/webapp/WEB-INF/spring/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:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://www.springframework.org/schema/beans 

    https://www.springframework.org/schema/beans/spring-beans.xsd

    http://www.springframework.org/schema/security 

    http://www.springframework.org/schema/security/spring-security.xsd">

    

    <beans:bean id ="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder" />

    <beans:bean id="customUserDetailsService" class="kro.rubisco.auth.CustomUserDetailsService" />

    

    <http pattern="/resources/**" security="none"></http>

    <http auto-config="true">

        <intercept-url pattern="/board/**" access="isAuthenticated()"/>

        <form-login

            login-page="/login"

            login-processing-url="/login"

            username-parameter="email"

            password-parameter="password" />

            

        <logout

            logout-url="/logout"

            logout-success-url="/"

            delete-cookies="JSESSIONID, remember-me" />

            

        <remember-me 

            token-validity-seconds="604800" 

            data-source-ref="dataSource" />

    </http>

    

    <authentication-manager>

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

            <password-encoder ref="bcryptPasswordEncoder"/>

        </authentication-provider>

    </authentication-manager>


</beans:beans>


이제 로그인시 remember-me를 체크하여 로그인하면 브라우저를 껏다가 다시 켜도 로그인 상태를 유지하는 것을 확인할 수 있습니다.

#스프링 레거시# 스프링 시큐리티# DelegatingFilterProxy# springSecurityFilterChain# FilterChainProxy# authentication# authorization# securityContext
댓글
자동등록방지
(자동등록방지 숫자를 입력해 주세요)