스프링 부트 보안
크로스사이트 스크립팅(cross-site scripting), 인증(authorization), 인가(authentication), 보안세션(secure session), 신원확인(identification), 암호화(encryption) 등 여러 전문 분야를 섭렵하며 보안을 적용하기란 쉬운일이 아니다.
스프링 시큐리티 개발팀은 각고의 노력 끝에 서비스 메서드에서 전체 웹애플리케이션에 이르기까지 보안을 쉽게 적용할 수 있도록 길을 열었다.
스프링 시큐리티 sping security는 AuthentifactionProvider를 중심으로 특화된 UserDetailsService를 제공하며, LDAP, 액티브 디렉터리(Active Directory), 커버로스(Kerberos), PAM, AOuth 등의 신원 공급자 시스템 identity provider system과 통합할 수 있다.
스프링 부트에 간단한 보안 적용
spring-boot-starter-security
spring init -d=web, thymeleaf, data-jpa, data-rest, mysql, security -g=com.apress.spring -a=spring-boot-journal-secure --package-name=com.apress.spring -name=spring-boot-journal-secure -x
인증시 사용할 GUID(globally Unique Identifier) 전역유일 ID 패스워드는 'Using defalut security password: xxx-xxxx-xx...' 텍스트로 알려준다.
브라우저를 열고 http://localhost:8080에 접속하면 인증 팝업창이 나타난다.
AuthenticationManager 인터페이스 구현체는 기본적으로 user라는 단 하나의 사용자 이름을 가진다.
사용자 이름 필드에 user를 넣고 비밀번호는 로그에서 보았던 값과 같은 GUID를 복사 후 붙여넣기한다.
스프링 부트 앱은 시동시 웹, 보안 의존체를 자동 구성하고 기본 보안 인증이 가능한 틀을 구축한다.
물론 실제 운영 환경에서는 이렇게 단순한 구조는 거의 없다.
application.properties 파일로 보안적용
#Security security.user.name=springboot security.user.password=isawesome |
기본 인증에 필요한 사용자 이름 및 비밀번호를 프로퍼티로 지정한 것이다.
curl -i http://springboot:isawesome@localhost:8080/api
HTTP URL 규격상 브라우저에서는 http://springboot:isawesome@localhost:8080 으로 접속하면 기본인증 창에 사용자 이름, 비밀번호을 입력할 필요없이 한번에 인증을 마칠수 있다.
HTTP URL의 일반 형식은 schema:[//[user:password@]host[:port]][/]path[?query][#fragment]
인메모리 보안
application.properties 파일은 실제적인 방안이 아니다. 이번에는 InMemorySecurityCongfiguration이라는 클래스로 인메모리 보안을 구현한다.
package com.apress.spring.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.authentication.configuration.EnableGlobalAuthentication; @Configuration @EnableGlobalAuthentication public class InMemorySecurityConfiguration { @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser("user").password("password").roles("USER") .and().withUser("admin").password("password").roles("USER","ADMIN"); } } |
@Configuration 스프링 부트에서 구성클래스로 쓸 클래스임을 밝힌다. XML 구성파일과 같다.
@EnableGolbalAuthentication 이 애노테이션을 붙인 클래스는 AuthenticationManagerBuilder의 전역 인스턴스를 구성하며 앱에 있는 모든 빈에 보안을 적용한다.
@Autowired/configureGlobal(AuthenticationManagerBuilder auth)
configureGlobal은 AuthenticationManagerBuilder를 자동 연결하는 메서드이다.
AuthenticationManagerBuilder는 개발자가 UserDetailsService와 인증 공급자를 추가해서 인증 로직을 쉽게 구현할 수 있게 도와준다.
여기에서는 인메모리 방식으로 inMemoryAuthentication 메서드를 호출해서 사용자 2명의 비밀번호와 권한을 각각 설정한다.
주석처리
#security.user.name=springboot #security.user.password=isawesome |
DB로 보안적용
사용자 정보는 DB에 저장하는것이 일반적이다.
@Configuration @EnableGlobalAuthentication public class JdbcSecurityConfiguration extends GlobalAuthenticationConfigurerAdapter { @Bean public UserDetailsService userDetailsService(JdbcTemplate jdbcTemplate) { RowMapper<User> userRowMapper = (ResultSet rs, int i) -> new User( rs.getString("ACCOUNT_NAME"), rs.getString("PASSWORD"), rs.getBoolean("ENABLED"), rs.getBoolean("ENABLED"), rs.getBoolean("ENABLED"), rs.getBoolean("ENABLED"), AuthorityUtils.createAuthorityList("ROLE_USER", "ROLE_ADMIN")); return username -> jdbcTemplate.queryForObject("SELECT * FROM ACCOUNT WHERE ACCOUNT_NAME = ?", userRowMapper, username);
}
@Autowired private UserDetailsService userDetailsService;
@Override public void init(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(this.userDetailsService); }
} |
GlobalAuthenticationConfigurerAdapter
JdbcSecurityConfiguration 클래스가 상속하는 추상 클래스로, SecurityConfigurer 인터페이스를 구현하므로 JdbcSecurityConfiguration 클래스에서 init 메서드를 재정의한다.
init(AuthenticationManagerBuilder)
GlobalAuthenticationConfigurerAdapter의 init 메서드를 재정의 한다.
이 메서드 내부의 AuthenticationManagerBuilder 인스턴스는 UserDetailsService 인스턴스를 구성하여 인메모리, LDAP, 기타, JDBC 기반의 인증을 구현한다.
@Bean/userDetailsService(JdbcTemplate)
JdbcTemplate 인스턴스를 구성하는 메서드다.
JdbcTemplate은 ResultSet 반환후 자신의 생성자(User)와 매칭되는 RowMapper를 이용해서 org.springframework.security.core.userdetails.User 인스턴스를 생성하며, 이 User 인스턴스는 username, password, enabled, accountNonExpired, credentialsNotExpired, accountNonLocked, authorities 컬렉션을 생성자 파라미터로 받는다.
ResultSet은 RowMapper와 매칭되는데 사용자를 추가하는 SQL 스키마가 기본 제공된다.
@Autowired/userDetailsService
빈으로 선언된 userDetailsService 메서드가 반환한 UserDetailsService 객체를 자동 연결한다.
구성 클래스 InMemorySecurityConfiguration을 작성했기 때문에 JdbcSecurityConfiguration 클래스를 우선 적용해서 사용자 정보를 MySQL DB에서 가져오게 하든지, 아니면 알기 쉽게 InMemorySecurityConfiguration 클래스에 붙인 @Configuration 및 @EnableGlobalAuthentication을 주석 처리해야한다.
하지만 프로파일을 지정해서 켜고 끄는 방식이 가장 좋다.
@Profile 애노테이션을 넣고 런타임에 -Dspring.active.profiles=memory 옵션을 줘서 프로파일을 켠다.
지금은 JDBC 보안을 구현하고 있으니 테이블 스키마는 schema.sql 파일에, 생성한 테이블에 넣을 데이터는 data.sql 파일에 각각 담는다.
-- SECURITY: USER ACCOUNT DROP TABLE IF EXISTS account; CREATE TABLE account ( ACCOUNT_NAME VARCHAR(255) NOT NULL, PASSWORD VARCHAR(255) NOT NULL, ID SERIAL, ENABLED BOOL DEFAULT true );
-- JOURNAL TABLE: ENTRY DROP TABLE IF EXISTS entry; CREATE TABLE entry ( ID BIGINT(20) NOT NLL AUTO_INCREMENT, CREATED DATETIME DEFAULT NULL, SUMMARY VARCHAR(255) DEFAULT NULL, TITLE VARCHAR(255) DEFAULT NULL, PRIMARY KEY(ID) ); |
schema.sql파일은 보안에 필수적인 account(계정) 테이블을 생성하는데 스프링 시큐리티 참고 문서에 나와 있는 스키마를 가져와 바꾼것이다.
그리고 application.properites 파일에 spring.jpa.hibernate.ddl-auto=create-drop으로 설정되어 있으므로 최근 생성(create)된 일기 테이블(entry)은 자동으로 삭제(drop)된다.
따라서 이 상태로 앱을 실행하면 "Can't find journal.entry table error (journal.entry 테이블을 찾을수 없어 오류가 발생했습니다)" 에러가 나니 불필요한 영향을 받지 않도록 프러퍼티를 주석처리한다.
#spring.jpa.hibernate.ddl-auto=create-drop |
data.sql 파일에는 사용자 2인의 계정정보와 일기 데이터 4개가 있다.
-- USERS IN JOURNAL INSERT INTO ACCOUNT(account_name, password) VALUES('springboot', 'isawesome'); INSERT INTO ACCOUNT(account_name, password) VALUES('springsecurity', 'isawesometoo'); -- JOURNAL DATA INSERT INTO ENTRY(title, summary, created) VALUES ('스프링 부트 입문','오늘부터 스프링 부트를 배웠다.','2016-01-02 00:00:00'); INSERT INTO ENTRY(title, summary, created) VALUES ('간단한 스프링 부트 프로젝트','스프링 부트 프로젝트를 처음 만들어보았다.','2016-01-03 00:00:00'); INSERT INTO ENTRY(title, summary, created) VALUES ('스프링 부트 해부 ','스프링 부트를 자세히 살펴보았다.','2016-02-02 00:00:00'); INSERT INTO ENTRY(title, summary, created) VALUES ('스프링 부트 클라우드 ','클라우드 파운드리를 응용한 스프링 부트를 공부했다.','2016-02-05 00:00:00'); |
리소스 보안
앱 전체가 아니라 특정 리소스만 보안을 걸고 싶을 때도 있다.
/api 끝점은 외부에서 POST, PUT, DELETE 액션을 수행하는 관문이기 때문에 아무나 함부로 접속하지 못하게 제한해야 한다.
리소스를 보호하는데 필요한 것은 com.apress.spring.config.ResourceSecurityConfiguration 클래스에 담는다.
@Configuration @EnableGlobalAuthentication public class ResourceSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/").permitAll() .antMatchers("/api/**").authenticated() .and() .httpBasic(); } } |
WebSecurityConfigurerAdapter
추상 클래스 WebSecurityConfigurerAdapter는 웹 애플리케이션 리소스를 구성하는 여러 방법 중 하나이다.
보통 configure(HttpSecurity) 와 cofigure(AuthenticationManagerBuilder) 메서드를 재정의하지만, 이 예제는 이미 JdbcSecurityConfiguration의 GlobalAuthenticationConfigurerAdapter에서 init(AuthenticationManagerBuilder) 메서드를 재정의한 산태라서 configure(HttpSecurity) 메서드 하나만 재정의하면 된다.
configure(HttpSecurity)
추상 클래스 WebSecurityConfigurerAdapter의 메서드를 재정의하여 자물쇠를 채울 리소를 지정한다.
HttpSecurity 인스턴스는 특정 HTTP 요청에 관하여 웹 기반 보안 구성을 한다.
기본은 모든 요청을 대상으로 하지만, 연결형 API로 범위를 좁힐수 있다.
웹 애플리케이션 루트를 .antMatchers("/").permitAll()해서 누구나 접근할수 있게 허용하고, .antMatchers("/api/**").authenticated()하여 /api 끝점에만 HttpBasicConfigurer 보안을 건다.
http://localhost:8080은 인증없이 바로 나온다.
http://localhost:8080/api로 이동할때만 기본 인증 창이 나타난다.
curl -i http://localhost:8080/api
상태코드 401 에러메서지는 Unauthorized(권한없음)로 세팅된 JSON 문자열이 반환된다.
curl -i http://springboot:isawesome@localhost:8080/api
또는
curl -u springboot:isawesome http://localhost:8080/api
이렇게 리소스를 보호해도 사용자 입장에선 통제구역에 접근할 때 흔히 보는 로그인 폼이 나타날 뿐이다.
연결형 API(빌더)를 제공하는 HttpSecurity 클래스는 자체 로그인 폼을 이미 갖고 있다.
ResourceSecurityConfiguration 클래스 코드를 수정
@Configuration @EnableGlobalAuthentication public class ResourceSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/").permitAll() .antMatchers("/api/**").authenticated() .and() .formLogin(); } } |
/api에 접속하면 기본 웹 폼(http://localhost:8080/login)으로 보내고 클라이언트가 사용자 이름과 비밀번호를 입력하면 다시 /api로 돌려보내라는 뜻이다.
http://localhost:8080/login 페이지로 이동하고 올바른 크레덴셜을 입력하여 인증을 마친 후엔 원래 접속하려고 했던 http://localhost:8080/api 페이지로 다시 돌아온다.
로그인과 로그아웃 폼을 바꿀수 있다.
@Configuration @EnableGlobalAuthentication public class ResourceSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/").permitAll() .antMatchers("/api/**").authenticated() .and() .formLogin().loginPage("/login").permitAll() .and() .logout().permitAll(); } } |
커스텀 로그인 페이지(/login)를 화면에 표시한다.
.logout().permitAll();
크레덴셜을 깔끔히 비우고 로그아웃한다.
로그인과 로그아웃 모두 permitAll() 메서드를 호출하며 끝난다.
인증 여부와 무관하게 누구나 접속가능한 끝점이기 때문이다.
login.html
<!DOCTYPE html> <html lang="en-US" xmlns="http://www.thymeleaf.org"> <head> <meta charset="utf-8"></meta> <meta http-equiv="Content-Type" content="text/html"></meta> <title>Login</title> <link rel="stylesheet" type="text/css" media="all" href="css/bootstrap.min.css"></link> <link rel="stylesheet" type="text/css" media="all" href="css/bootstrap-glyphicons.css"></link> <link rel="stylesheet" type="text/css" media="all" href="css/styles.css"></link> </head> <body> <div class="container"> <div class="content"> <p th:if="${param.logout}" class="alert">로그아웃 되었습니다.</p> <p th:if="${param.error}" class="alert alert-error">오류가 발생했습니다. 다시 시도해주세요.</p> <header class="page-header"> <h1>스프링 부트 일기 로그인</h1> </header> <form name="form" th:action="@{/login}" action="/login" method="POST"> <input type="text" name="username" value="" placeholder="Username"></input> <input type="password" name="password" placeholder="Password"></input> <input type="submit" id="login" value="Login" class="btn btn-primary"></input> </form> </div> </div> </body> </html> |
th:if="${param.logout}" / th:if="${param.error}"
타임리프의 조건 구문
logout 파리미터, error 파리미터가 각각 존재하는지 체크
요청 URL이 /login?logout이면 /logout으로 인식해서 크레덴셜을 싹 비우고 "로그아웃이 되었습니다."라고 알린다.
URL이 /login?error이면 "오류가 발생했습니다. 다시 시도해주세요."라는 에러 메시지를 나타낸다.
th:action="@{/login}" / method="POST"
/login 끝점으로 사용자 이름과 비밀번호를 전송한다.
성공하면 /api로 이동하고 실패하면 에러메시지를 낸다.
<input type="text" name="username" value="" placeholder="Username"></input>
<input type="password" name="password" placeholder="Password"></input>
<!DOCTYPE html> <html lang="en-US" xmlns="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <head> <meta charset="utf-8"></meta> <meta http-equiv="Content-Type" content="text/html"></meta> <title>스프링부트 일기</title> <link rel="stylesheet" type="text/css" media="all" href="css/bootstrap.min.css"></link> <link rel="stylesheet" type="text/css" media="all" href="css/bootstrap-glyphicons.css"></link> <link rel="stylesheet" type="text/css" media="all" href="css/styles.css"></link> </head> <body> <div class="container"> <header class="page-header"> <h1>스프링 부트 일기</h1> </header> <p sec:authorize="isAuthenticated()"> <form th:action="@{/logout}" method="post"> <input type="submit" value="로그아웃"></input> </form> </p> <ul class="timeline"> <div th:each="entry, status :${journal}"> <li th:attr="class=${status.odd}?'timeline-inverted':''"> <div class="tl-circ"></div> <div class="timeline-panel"> <div class="tl-heading"> <h4><span th:text="${entry.title}">제목</span></h4> <p><small class="text-muted"><i class="glyphicon glyphicon-time"></i> <span th:text="${entry.createdAsShort}">에 작성.</span></small></p> </div> <div class="tl-body"> <p><span th:text="${entry.summary}">요약</span></p> </div> </div> </li> </div> </ul> </div> </body> </html> |
package com.apress.spring.web; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.ModelAndView; import com.apress.spring.repository.JournalRepository; @RestController public class JournalController { private static final String VIEW_INDEX = "index"; private static final String VIEW_LOGIN = "login";
@Autowired JournalRepository repo;
@RequestMapping(value="/", method=RequestMethod.GET) public ModelAndView index(ModelAndView modelAndView) { modelAndView.setViewName(VIEW_INDEX); modelAndView.addObject("journal", repo.findAll()); return modelAndView; }
@RequestMapping(value="/login") public ModelAndView login(ModelAndView modelAndView) { modelAndView.setViewName(VIEW_LOGIN); return modelAndView; }
} |
index.html페이지에서 타임리프 라이브러리 xmlns:sec namespace를 선언하여
pom.xml에 추가해야한다.
pom.xml
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-taglibs</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity4</artifactId> </dependency> <dependency> |
'JAVA > Spring Boot' 카테고리의 다른 글
스프링 부트 기본설정 (0) | 2020.08.20 |
---|---|
스프링부트 보안 OAuth2 (0) | 2018.04.12 |
스프링부트 웹 개발 (샘플) (0) | 2018.04.06 |
Spring Data JPA (0) | 2018.04.02 |
스프링 부트 데이터 액세스 (0) | 2018.02.12 |