목표
이 글에서는 스프링 시큐리티의 전체적인 구조와 같은 원론적인 내용보다는
기본 세팅에 대한 코드를 보여주고,
해당 코드들이 무슨 역할을 하는지를 위주로 설명합니다.
spring security를 이용해 인증과 인가를 구현하고 현재 로그인된 정보를 이용해
thymeleaf에서 다른 화면을 보여주기까지 구현합니다.
로그인정보에 대한 데이터로서 DB까지 이용합니다.
순서는 다음과 같다'
- 개발환경
- security 설정
- 동작 설명
개발환경
- Springboot 2.7.8
- H2
- JPA
- thymeleaf
- spring security
- lombok
build.gradle
dependencies {
//starter
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
//tyemeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' //tymeleaf에서 security 관련태그 사용
//db
implementation 'com.h2database:h2:1.4.199'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
//security
implementation 'org.springframework.boot:spring-boot-starter-security'
// lombok,test, devtools 등
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
springboot는 기본적으로 gradle에 dependency 버전을 명시 안해주면
현재 springboot에 맞는 버전을 자동으로 다운받는다.
security도 springboot버전에 따라 받는 버전이 다르다.
springboot 3.0부터는 security 설정 및 사용법이 좀(사실 많이) 다르기 때문에
springboot 3.0부터는 다른 글을 읽는게 더 좋을 것이다.
Security 적용하기
대부분의 설명들은 주석을 달아놨지만, 파일들이 많아 헷갈리는 경우는 아래 파일들을
순서대로 보시면 됩니다.
SpringSecurityConfig
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//비밀번호 인코딩. security는 이게 안 되어있으면 알아서 에러일으켜서 꼭 해줘야함.
// 그럼 무조건 insert하는 부분에서도 encoding 되서 넣어야 함.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
LoginIdPwValidator loginIdPwValidator;
@Override
protected void configure(HttpSecurity http) throws Exception {
// antMatchers()는 패턴입력, 그 후에 어떻게 처리할지
// anyRequest는 앞에서말한 패턴외에 전부. 그 후에 어떻게 처리할지.
http
.authorizeRequests()
.antMatchers("/manage").hasAuthority("ROLE_ADMIN") // manage는 ADMIN이라는 권한까지 체크해야되고. DB에는 "ADMIN" 만 있어야 됨. "ROLE_"은 자동으로 붙음
.antMatchers("/hch").authenticated() //hch는 인증필요하고
.antMatchers("/**").permitAll() //그 외는 그냥 접근가능
.and()
.formLogin() //로그인페이지 설정
.loginPage("/login")
.loginProcessingUrl("/loginProc") //controller에 만들필요는 없고 login페이지에서 form태그의 action값을 의미
.usernameParameter("id") // 마찬가지로 form태그의 파라미터 이름
.passwordParameter("pw")
.defaultSuccessUrl("/", false) //성공했을 때 이동 url. hch갈려다가 필터에걸려서 로그인 한 경우에는 그대로 hch가고, 그냥 login페이지 요청해서 성공했을 때는 "/"로 가고
.permitAll()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) //로그아웃 요청 url
.logoutSuccessUrl("/"); // 로그아웃 후에 이동할 url
}
// 정적파일들은 인증필요없도록
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/js/**", "/static/css/**", "/static/img/**", "/static/frontend/**");
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(loginIdPwValidator);
}
}
LoginIdPwValidator
@Service
public class LoginIdPwValidator implements UserDetailsService {
@Autowired
private UserInfoRepository userInfoRepository;
@Override
public UserDetails loadUserByUsername(String insertedId) throws UsernameNotFoundException {
Optional<UserInfo> option = userInfoRepository.findById(insertedId);
UserInfo user=null;
if(option.isPresent()){
user =userInfoRepository.findById(insertedId).get();
}
if (user == null) {
return null;
}
String pw = user.getPw(); // 인코딩된 값이다.... db에 저장하는 곳에서는 무조건 인코딩해서 저장해야된다.
String roles = user.getRole();
return User.builder()
.username(insertedId)
.password(pw)
.roles(roles)
.build();
}
}
단순히 id가지고 DB 조회 후 null 여부로 로그인과정을 만들었다.
로그인 성공시에는 UserDetails를 return한다. 필요한 값들은 DB에서 가져온 UserInfo객체로 세팅한다.
application.yml
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/test
driver-class-name: org.h2.Driver
username: sa
password : sa
jpa:
database-platform: org.hibernate.dialect.H2Dialect
properties.hibernate.hbm2ddl.auto: create
showSql: true
UserInfo
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
@Id
private String id;
private String pw;
private String role;
}
UserInfoRepository
public interface UserInfoRepository extends JpaRepository<UserInfo,String> {
}
HomeController
@Controller
public class HomeController {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
UserInfoRepository userInfoRepository;
@PostConstruct
public void init(){
UserInfo userInfo1=new UserInfo("id1" , passwordEncoder.encode("password"),"ADMIN");
UserInfo userInfo2=new UserInfo("id2" , passwordEncoder.encode("password"),"USER");
userInfoRepository.save(userInfo1);
userInfoRepository.save(userInfo2);
}
@GetMapping("/")
public String home(){
return "home";
}
}
LoginController
@Controller
public class LoginController {
@RequestMapping("/login") //loginPageURl에서 지정한 url
public String loginPage() {
return "login";
}
@RequestMapping("/manage") //ROLE_ADMIN 이 없으면 들어올 수 없어요..
public String manage() {
return "manage";
}
@RequestMapping("/hch")
public String hch() // 인증(로그인)만 하면 되고
{
return "hch";
}
@RequestMapping("/anything") // 그 외것들은 그냥 접근 가능. 사실 "/"도 인증필요없이 접근가능
public String anything() {
return "anything";
}
// // 로그인정보를 서버에서 사용할 때는 이렇게 사용함
// @RequestMapping("/someURL")
// public String someURL(Principal principal){
// String id = principal.getName(); //메소드이름은 getName()이지만 사실상 로그인할 때 썻던 id를 줍니다
//
// //id로 DB조회해서 회원정보 얻는 등 이것저것 할 수 있다.
// return "some";
// }
}
anything html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
아무조건없이 올 수 있는 페이지
</body>
</html>
hch.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<h1> 인증만 헀다면 올 수 있는 페이지. 밑에는 현재 로그인정보</h1>
<h2 sec:authentication="principal" ></h2>
</body>
</html>
home.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div sec:authorize="hasRole('ROLE_ADMIN')" >
ROLE_ADMIN일 때만 보이는 구역
<h1 sec:authentication="principal" ></h1>
<h1 sec:authentication="name" ></h1>
<h1 sec:authentication="principal.authorities"></h1>
</div>
<hr>
<div sec:authorize="isAuthenticated()">
인증(로그인)만 했다면 보이는 구역
<h1 sec:authentication="principal" ></h1>
<h1 sec:authentication="name" ></h1>
<h1 sec:authentication="principal.authorities" ></h1>
</div>
<hr>
<div sec:authorize="!isAuthenticated()">
인증(로그인)안됐을때만 보이는 구역
</div>
</body>
</html>
login.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<form method="post" action="/loginProc">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}" />
<input type="text" name="id">
<input type="password" name="pw" maxlength="32" >
<button type="submit">로그인</button>
</form>
</body>
</html>
manage.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<h1> 권한이 ROLE_ADMIN인 분만 올 수 있는 페이지. 밑에는 현재 로그인정보</h1>
<h2 sec:authentication="principal" ></h2>
</body>
</html>
동작설명
1. 서버를 킨다.
서버가 켜지면서 bean 등록을 합니다.
homeController 빈을 만든 후 @PostConstruct 메소드가 실행되면서 DB에 UserInfo가 저장됩니다.
@Controller
public class HomeController {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
UserInfoRepository userInfoRepository;
@PostConstruct
public void init(){
UserInfo userInfo1=new UserInfo("id1" , passwordEncoder.encode("password"),"ADMIN");
UserInfo userInfo2=new UserInfo("id2" , passwordEncoder.encode("password"),"USER");
userInfoRepository.save(userInfo1);
userInfoRepository.save(userInfo2);
}
@GetMapping("/")
public String home(){
return "home";
}
}
서버 실행 후 DB 상태. pw가 인코딩되서 들어간 값이 들어갔다.
(pw를 인코딩해서 넣는 이유는 좀 있다 설명합니다. )
id1의 비밀번호는 password, 권한은 ADMIN이고
id2의 비밀번호는 password, 권한은 USER입니다.
같은 password를 인코딩했지만 DB에 들어가는 값이 다릅니다.
인코딩방식의 더 자세한건 https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.html 를 참고하세요.
2. URL : "/hch" 이동
.antMatchers("/manage").hasAuthority("ROLE_ADMIN") // manage는 ADMIN이라는 권한까지 체크해야되고. DB에는 "ADMIN" 만 있어야 됨. "ROLE_"은 자동으로 붙음
.antMatchers("/hch").authenticated() //hch는 인증필요하고
.antMatchers("/**").permitAll() //그 외는 그냥 접근가능
SpringsecurityConfig에서 "/hch"는 인증이 필요한 url입니다.
로그인을 안해놓았기 때문에 로그인페이지로 이동하게 됩니다.
3. 로그인페이지
로그인을 위해 각각 id2,password를 입력하고 버튼을 누릅니다.
<form method="post" action="/loginProc">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}" />
<input type="text" name="id">
<input type="password" name="pw" maxlength="32" >
<button type="submit">로그인</button>
</form>
로그인 form의 action은 "/loginProc"이고 이는 SrpingSecurityConfig의 loginProcessingUrl과 같습니다.
.and()
.formLogin()
.loginPage("/login")//로그인페이지 설정
.loginProcessingUrl("/loginProc") /
※로그인 폼에 있는 csrf에 관한 건 https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80 를 참고하자.
4. spring security에서 설정한 loginProcessingUrl에 관한 일을 시작합니다.
SpringSecurityConfig에 보면 다음과 같이 loginIdPwValidator를 auth.userDetailsService()에 인자로 넣어줍니다.
@Autowired
LoginIdPwValidator loginIdPwValidator; //로그인 과정을 직접 구현. UserDetailService를 상속받았다. 우리가 만들거다.
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(loginIdPwValidator);
}
LoginIdPwValidator는 다음과 같습니다.
@Service
public class LoginIdPwValidator implements UserDetailsService {
@Autowired
private UserInfoRepository userInfoRepository;
@Override
public UserDetails loadUserByUsername(String insertedId) throws UsernameNotFoundException {
Optional<UserInfo> option = userInfoRepository.findById(insertedId);
UserInfo user=null;
if(option.isPresent()){
user =userInfoRepository.findById(insertedId).get();
}
if (user == null) {
return null;
}
String pw = user.getPw(); // 인코딩된 값이다.... db에 저장하는 곳에서는 무조건 인코딩해서 저장해야된다.
String roles = user.getRole();
return User.builder()
.username(insertedId)
.password(pw)
.roles(roles)
.build();
}
}
화면에서 입력한 id가지고 DB 조회한 다음
id,pw,role 등이 세팅된 UserDetails객체를 만들어서 넘기면
security는 UserDetails객체와 화면에서 넘어온 id,pw값을 비교합니다.
이 때 UserDetails에 pw는 DB에 있던 encoding된 값이고 화면에서 넘어온 pw값은 "password" 인데
passwordEncoder.matches( rawPassword,encodedPassword)를 이용해 비교합니다.
비교에 성공했다면 로그인 성공으로 간주합니다.
(물론 비교과정도 직접 커스터마이징할 수 있습니다)
5. 로그인에 성공했기 때문에 원래 가려고했던
"/hch"로 이동합니다.
현재 id2로 로그인된 상태(권한:USER)인데 "/manage"로 이동한다면 다음과 같이 권한 에러를 봅니다.
만약 id1로 로그인한다면 "/manage", "/hch" 전부 이동 가능합니다.