스프링 프레임워크에서 로그인과 관련된 라이브러리 중 가장 많이 사용되는 라이브러리(프레임워크)는 시큐리티(Security)라 할 수 있습니다.
xml 파일을 통하여 구현 하거나 또는 Java 코드를 통하여 로그인과 관련된 기능을 정의할 수 있습니다.
주관적인 생각이지만 xml 에서 사용하는 방법 보다는 Java코드를 통하여 만드는 방법이 좀 더 쉬운 것 같습니다.
스프링 부트에서도 시큐리티 설정을 위한 작업 순서를 정하여 봅니다!
1. 라이브러리 추가
2. 시큐리티에서 로그인 후 사용가능한 페이지 설정, 로그인 없이 사용가능한 페이지 설정
3. 시큐리티가 사용할 가치 있는 객체(Value of Object) 설정
4. 시큐리티가 데이터베이스에 연결하여 사용할 서비스 설정
위 4가지 단계에 맞추어 작성하여 보겠습니다!
#1. 라이브러리 추가
먼저 관련된 라이브러리를 추가하여 줍니다.
* maven, pom.xml 기준 입니다!
<!-- 시큐리티 라이브러리 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- sql lite -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<!-- db 저장 jpa-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
시큐리티 라이브러리(프레임워크)는 데이터베이스가 있어야 사용할 수 있습니다!
그리하여 다른 데이터베이스(몽고DB, 레디스, Mysql, Oracle) 같은 경우에는 따로 설치가 필요하기 때문에 여기서는 sqlite 데이터베이스를 사용하였습니다.
* 사용중인 데이터베이스가 존재 한 다면 해당 데이터베이스를 사용하여도 됩니다.
손쉬운 ORM을 위하여 JPA 프레임워크를 사용 하였습니다.
마찬가지로 Mybaits 처럼 다른 프레임워크를 사용해도 괜찮습니다.
#2. 시큐리티에서 로그인 후 사용가능한 페이지 설정, 로그인 없이 사용가능한 페이지 설정
로그인이 필요한 페이지와 로그인이 필요없는 페이지를 정해야 합니다.
일반적으로 로그인이 필요없는 페이지는 웹 페이지에서 아이디와 비밀번호를 입력하는 페이지를 의미 합니다.
또한 css 파일, js 파일 및 image 파일 같은 단순한 요청도 포함 시켜야 합니다.
로그인이 필요한 페이지는 로그인 후 사용 가능하는 페이지를 의미 합니다.
먼저 설정과 관련된 전체적인 코드를 살펴보겠습니다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import rts.test.서비스;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
서비스 service;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public static NoOpPasswordEncoder noPasswordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**", "/index.html");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/member.html*").authenticated()
.antMatchers("/super.html*").hasAuthority("super")
.antMatchers("/normal.html*").hasAnyAuthority("super","normal")
.antMatchers("/**").permitAll();
http.formLogin()
.loginProcessingUrl("/login")
.loginPage("/index.html")
.defaultSuccessUrl("/member.html").failureUrl("/deny.html")
.permitAll().and().csrf().disable();
http.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/index.html")
.invalidateHttpSession(true);
http.exceptionHandling()
.accessDeniedPage("/deny.html");
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(서비스).passwordEncoder(noPasswordEncoder());
}
}
#2-0 WebSecurityConfigurerAdapter 추상 클래스 상속
먼저 시큐리티에서 기능에 대한 전체적인 설정을 해 주도록 합니다.
이를 위해서 WebSecurityConfigurerAdapter 라는 추상클래스를 상속(extends)받도록 합니다.
해당 클래스를 상속받아서 기능을 구현하면되는데...2022년도 기준으로 deprecated 된게 조금 아쉽습니다..ㅠ
그래도 deprecated된지 얼마 되지 않았으니 WebSecurityConfigurerAdapter 클래스를 상속받아서 진행하도록 합니다.
#2-1 패스워드 인코더 설정
패스워드 인코더는 말 그대로 비밀번호에 대한 인코딩 설정 입니다.
시큐리티에서는 비밀번호에 대해서 암호화할지 아니면 평문으로 사용할지 2가지 클래스를 위와같이 제공하고 있습니다.
이름에서 알 수 있듯이 PasswordEncoder 클래스는 비밀번호를 암호화 하며, NoOpPasswordEncoder 클래스는 비밀번호를 암호화 하지 않습니다.
이렇게 만들어진 2개의 메소드는 사용자가 로그인 요청을 보내오게 되면 암호화 또는 평문화를 통해 데이터베이스결과를 가지고 질의하고 비교하게 됩니다.
#2-2 로그인시 사용할 서비스 등록
스프링 시큐리는 데이터베이스에 연결이 되어야만 합니다.
그러므로 이러한 데이터베이스에 연결하여 사용자 정보를 가져오는 서비스(Service) 형식의 클래스가 필요하게 됩니다.
해당 서비스 구현방법에 대해서는 #4에서 작성하겠습니다.
이렇게 작성된 서비스를 오버라이딩을 통해서 configure 라는 메소드를 재 정의하여 주도록 합니다.
이때 #2-1에서 만든 메소드를 포함시키도록 합니다.
#2-3 일반 요청에 대한 시큐리티 적용 제외 설정
이미지, js파일 및 css 파일 등 로그인이 필요하지 않는 요청에 대해서 제외를 해 주어야 합니다.
그렇지 않는다면 모든 요청에 대해서 로그인 여부를 확인하기 때문에 생각지 못한 문제가 발생할 수 있습니다.
마찬가지로 configure라는 메소드를 정의하여 줍니다.
#2-2에서의 이름이 같지만 자세히 살펴보면 파라미터가 다른 오버로딩 메소드 입니다.
이렇게 제외 요청을 정해두게 되면 css파일이 없거나, image 및 js 파일을 받지 못해서 화면이 깨지는 경우는 없습니다.
위 /css/, /js/ 등의 경로는 제가 정의한 경로 입니다.
프로젝트의 구성에 따라 스타일, js파일 및 이미지 파일이 존재하는 경로 또는 로그인없이 접근 가능한 경로는 상황에 맞추어 대입하면 됩니다.
#2-4 로그인이 필요한 경우, 로그인이 필요없는 경우 설정
이제 조금 복잡한 부분 입니다.
로그인, 로그아웃에 대한 프로세스는 configure라는 메소드에서 정의 할 수 있습니다.
해당 메소드도 오버로딩 타입의 메소드 입니다.
@Override
protected void configure(HttpSecurity http) throws Exception {
//인증이 필요한 요청입니다.
http.authorizeRequests()
.antMatchers("/member.html*").authenticated()
.antMatchers("/super.html*").hasAuthority("super")
.antMatchers("/normal.html*").hasAnyAuthority("super","normal")
.antMatchers("/**").permitAll();
//시큐리티가 감지하는 로그인 시도 입니다.
http.formLogin()
.loginProcessingUrl("/login")
.loginPage("/index.html")
.defaultSuccessUrl("/member.html").failureUrl("/deny.html")
.permitAll().and().csrf().disable();
//시큐리티가 감지하는 로그아웃 시도 입니다.
http.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/index.html")
.invalidateHttpSession(true);
//시큐리티가 감지하는 접근거부(오류, 405 같은) 페이지 경로 입니다.
http.exceptionHandling()
.accessDeniedPage("/deny.html");
}
먼저 인증이 필요한 요청에 대해 살펴보겠습니다.
http.authorizeRequests()
.antMatchers("/member.html*").authenticated() //01
.antMatchers("/super.html*").hasAuthority("super") //02
.antMatchers("/normal.html*").hasAnyAuthority("super","normal") //03
.antMatchers("/**").permitAll(); //04
antMatchers 메소드는 요청타입을 의미합니다. 01번을 살펴보면 /member.html 이라는 요청이 들어오게 되면 authenticated 메소드가 적용되는데, 해당 메소드는 로그인이 된 상태를 의미 합니다.
그러므로 member.html의 요청은 "로그인이 된 상태" 에서만 접근 가능 합니다.
다음으로 02번입니다. 02번은 /super.html 요청이 들어온 경우 hasAuthority 메소드가 적용되어 있습니다.
hasAuthority메소드는 사용자가 정의한 권한의 종류를 의미하는데, super.html 페이지에 접근하려면 권한이 super라는 권한을 가지고 있어야 한다는 것을 의미 합니다.
다음으로 03번입니다. /normal.html 요청은 02번과 마찬가지로 권한이 필요하는데 hasAnyAuthority 메소드가 적용되어 있습니다. 해당 hasAnyAuthority 메소드는 사용자가 지정한 n개의 권한을 확인 합니다.
그러므로 /normal.html 요청은 super 또는 normal 권한이 있는 사용자만 접근 가능 합니다.
마지막 04번은 모든 요청에 대해서 사용자는 접근 가능한 것을 의미 합니다.
물론 모든 요청에 대해서 접근이 가능하지만 01번 ~ 03번 같은 경우는 "로그인이 된 경우" 라는 조건이 붙습니다.
여기서 작성된 super, normal 이라는 권한은 설명을 위해 제가 만든 값 입니다.
그러므로 이러한 권한 코드 값은 사용자가 정의한 값을 사용해도 무방 합니다.
다음으로 사용자가 로그인을 시도하는, 즉 로그인 버튼을 눌러서 로그인을 하는 경우 입니다.
http.formLogin()
.loginProcessingUrl("/login") //05
.loginPage("/index.html") //06
.defaultSuccessUrl("/member.html") //07
.failureUrl("/deny.html") //08
.permitAll().and().csrf().disable(); //09
05번은 사용자가 로그인 요청을 보내는 주소 입니다.
컨트롤러에서 로그인 요청을 받는 메소드를 만드는 것이 아니라 위 코드처럼 적어주면 됩니다.
시큐리티는 login 이라는 요청에 대해서 독자적으로 컨트롤러를 동작시키며, 앞서 설정한 #2-2에서 등록한 서비스가 비지니스로직을 수행하게 됩니다.
그러므로!
로그인을 하는 form테그, 또는 ajax, axios 등 url 값을 반드시 /login으로 해 주어야 합니다.
마찬가지로 login이라는 주소는 제가 만든 값 이며, login 주소는 원하는 값으로 바꾸어도 상관 없습니다.
#####주의#####
주의해야 되는 점은 시큐리티는 사용자 아이디와 비밀번호에 대한 값을 username, password로 보내도록 되어 있습니다.
그러므로 데이터를 전송 할 때 반.드.시 username, password을 키 값으로 하여 로그인 요청을 해야 합니다!
06번은 로그인을 하는 페이지를 의마하며, 07번은 로그인이 성공하면 보내지는 기본 페이지를 의미 합니다.
08번은 로그인이 실패하면 가능 페이지를 의미 합니다.
마지막으로 09번은 CSRF 공격을 위한 설정 입니다.
여기서는 원활한 설정을 위해서 disable 메소드를 통해 꺼두로독 합니다.
해당 개념을 설명하기에 다소 장황하므로 아래 제 포스팅 주소를 걸어두겠습니다.
https://lts0606.tistory.com/549
이제 마지막 설정구간 입니다.
//10
http.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/index.html")
.invalidateHttpSession(true);
//11
http.exceptionHandling()
.accessDeniedPage("/deny.html");
10번은 로그아웃 요청에 대해서 동작을 정의하는 구간 입니다. logout 이라는 요청을 브라우저에서 보내게 되면 index.html 페이지로 이동시켜주며 이때 생성한 세션을 만료시키게 되어 있습니다.
그러므로!
로그인을 하는 form테그, 또는 ajax, axios 등 url 값을 반드시 /logout으로 해 주어야 합니다.
물론 logout 주소는 바꾸어도 상관 없습니다.
11번은 메소드 이름에서 알 수 있듯이 비정상적인 접근 또는 오류가 발생하면 deny.html 이라는 페이지로 이동하게 한 설정 입니다.
#3. 시큐리티가 사용할 가치 있는 객체(Value of Object) 설정
시큐리티는 데이터베이스가 필요한 라이브러리(프레임워크) 입니다.
그렇게 때문에 데이터베이스에 접근하고 난 이후 데이터를 ORM하는 클래스의 정의가 필요 합니다.
먼저 완성된 코드를 살펴봅니다.
import java.util.List;
import java.util.ArrayList;
import java.util.Collection;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@javax.persistence.Entity
@Getter
@Setter
@ToString
public class DataBaseVo implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer idx;
private String name;
private String auth; //권한
private String password; //비밀번호
private String userId; //사용자아이디
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<GrantedAuthority>();
list.add(new SimpleGrantedAuthority(this.auth));
return list;
}
@Override
public String getUsername() {
return this.userId;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
#3-0 UserDetail 상속
JPA 나 Mybatis로 데이터를 매핑 할 때 반드시 위 클래스처럼 사용자가 정의한 클래스로 매핑해야 합니다.
* 컬렉션의 HashMap, Map 같은 걸로 하면 안됩니다!
이때 시큐리티에서 제공하는 UserDetail 인터페이스를 상속받도록 합니다.
지금 코드는 JPA에서의 데이터 매핑을 위한 구조 이므로 전부 따라할 필요는 없습니다.
#3-1 권한 컬럼 묶어주기
시큐리티에서 권한으로 사용할 컬럼을 묶어주도록 합니다.
데이터베이스에서 질의를 하고 난 이후에 매핑된 결과에 권한과 관련된 데이터가 있다면 오버로딩한 getAuthorities 메소드를 정의 해 주도록 합니다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<GrantedAuthority>(); //권한 목록
list.add(new SimpleGrantedAuthority(this.auth)); //권한 담기!
return list;
}
위 코드처럼 1명의 사용자가 n개의 권한을 가질 수 있으므로 List 컬렉션에 사용자의 권한 값을 담아 SimpleGrantedAuthority클래스로 생성하여 줍니다.
SimpleGrantedAuthority클래스는 사용자의 권한 값을 문자형식으로 받습니다.
DataBaseVo 클래스는 권한을 auth 라는 문자로 만든, 권한이 1개인 클래스 입니다.
그러므로 list에 1개만 더해주면 되겠습니다.
#3-2 사용자 아이디 컬럼 묶어주기
사용자 아이디 컬럼을 묶어주기 위해서 오버로딩한 getUsername을 정의하도록 합니다.
여기서는 userId라는 값이 사용자 아이디로 사용되었으므로 getUsername메소드에서 해당 값을 리턴하도록 합니다.
그외 나머지 메소드의 역할을 표로 나타내 보았습니다.
메소드 명 | 설명 |
getAuthorities() | 계정이 갖고있는 권한 목록 입니다. |
getPassword() | 계정의 비밀번호를 가져 옵니다. |
getUsername() | 계정의 아이디(이름)를 가져 옵니다 |
isAccountNonExpired() | 계정이 만료되지 않았는 지 리턴 합니다. (true: 만료안됨) |
isAccountNonLocked() | 계정이 잠겨있지 않았는 지 리턴 합니다. (true: 잠기지 않음) |
isCredentialNonExpired() | 비밀번호가 만료되지 않았는 지 리턴 합니다. (true: 만료안됨) |
isEnabled() | 계정이 활성화(사용가능)인 지 리턴 합니다. (true: 활성화) |
#3-3 비밀번호 컬럼 묶어주기
시큐리티 라이브러리(프레임워크)에서 비밀번호는 password 라는 키 값으로 사용되고 있습니다.
pass_word, pwd, secret 등등...다른 컬럼을 굳이 사용하거나 변경하려 하지 마시고 password라는 이름으로 컬럼을 만들고 마찬가지로 매핑하는 클래스도 password로 하는 것을 추천드립니다.
#4. 시큐리티가 데이터베이스에 연결하여 사용할 서비스 설정
이제 마지막 단계 입니다.
실제 데이터베이스에 조회하고 결과를 가져오는 서비스를 생성하여야 합니다.
이렇게 만들어진 서비스는 #2번의 #2-2 메소드에 넣어주어야 합니다.
import org.springframework.stereotype.Service;
import java.util.HashMap;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import rts.test.dbinit.DataBaseVo;
@Service("TestService")
public class TestService implements UserDetailsService {
private final DBRepository dao; //데이터베이스 연결 DAO 입니다.
public TestService(DBRepository dao) {
this.dao = dao;
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
DataBaseVo vo = dao.findByUserId(userId); //조회를 여기서 합니다.
return vo;
}
}
#4-0 UserDetailsService 상속
가장 먼저해야 되는 것은 마찬가지로 시큐리티에서 제공하는 UserDetailsService 인터페이스를 상속 받도록 합니다.
그러면 loadUserByUsername 이라는 메소드를 오버라이드 하게 되는데 해당 메소드가 바로 데이터베이스에 질의를 해서 결과를 반환해야되는 메소드 입니다.
loadUserByUsername 메소드에서의 재미있는 점은, 파라미터로는 사용자 아이디만 받는 다는 것 입니다.
시큐리티는 내부적으로 사용자의 아이디만 데이터베이스에서 조회를 한 뒤에 가져온 값에서 비밀번호(password) 값을 추출하여 데이터베이스 결과와 매핑하도록 구현되어 있습니다.
그러므로 사용자 비밀번호를 데이터베이스에서 조회하는 코드는 작성해서는 안됩니다.
* 예 : select * from user where id=? and password = ? → 쿼리에 password 질의코드가 없어야 합니다!!!
또한 password 컬럼이 사용자 테이블에 존재해야 하며, 매핑하는 클래스도 password 라는 문자열 값이 존재해야 합니다.
여기까지 했다면 이제 정상적으로 동작하는 모습을 볼 수 있습니다!
위 설명에 대한 전체 소스코드 입니다.
https://github.com/TaeSeungRyu/sample/tree/main/SpringSecuritySample
간단하게 구현하여 본 스프링 부트에서의 시큐리티 적용방법 이였습니다.
SPA 방식이 점점 늘어나고 있는 추세에서 시큐리티가 잘 쓰이는지는 모르겠습니다..^-^;
궁금한점 또는 틀린 부분은 언제든 연락 주세요!
'Spring framework > Spring boot' 카테고리의 다른 글
TemplateInputException An error happened during template parsing(타임리프 오류) (0) | 2022.07.22 |
---|---|
Spring boot thymeleaf layout decorate(layout decorate가 동작하지 않을 때) (0) | 2022.07.22 |
간단하게 구성하는 SpringBoot Websokcet server(스프링부트 웹소켓 서버) (3) | 2021.12.27 |
QuartzJobBean에서 ApplicationContext을 통해 서비스(Bean) 받기 (2) | 2021.11.26 |
SpringBoot ErrorController(스프링부트 에러컨트롤러)에서 getErrorPath 메소드는 어디에? (0) | 2021.11.25 |
댓글