보통 스프링 시큐리티를 적용하면 어느 페이지로 이동하건
시큐리티 로그인을 요구한다고 설명했다.
자세한 설명은 아래 자료 참고
https://happy-fun.tistory.com/153
스프링 시큐리티 세팅
스프링 시큐리티를 프로젝트에 작동시키면 (pom.xml 에 시큐리티 의존성 설정되어 있으면 작동) 내가 만드는 프로젝트에는 아래와 같은 의존성 설정했음 파일 org.springframework.security spring-security-tag
happy-fun.tistory.com
그런데 SecurityConfig.java 파일에 내가 원하는 로그인 처리를 설정한다고 설명했고,
그에 이어서 설명하고자 한다.
1. GetMapping - 로그인 화면 이동
위의 글에서도 설명했듯이
SecurityConfig.java 파일에서
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Bean
public BCryptPasswordEncoder encode() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests()
.antMatchers("/", "/user/**").authenticated()
.anyRequest().permitAll()
.and()
.formLogin()
.loginPage("/auth/signin") // GET
.loginProcessingUrl("/auth/signin") // POST
.defaultSuccessUrl("/");
}
}
여기서 보면,
.loginPage("/auth/signin") 으로 처리했기 때문에,
로그인이 필요한 페이지로 이동하면
/auth/signin 페이지로 get요청되어 이동하게 된다.
이에 대한 getmapping 설정이 필요하므로
<AuthController.java> 파일
@GetMapping("/auth/signin")
public String signinForm() {
return "auth/signin";
}
위와 같이 /auth/signin 으로 설정해준다.
그러면 아래와 같이 설정한 signin.jsp 파일에 의해 로그인 화면이 나오게 될 것이다.
<signin.jsp> 파일
<form class="login__input" action="/auth/signin" method="POST">
<input type="text" name="username" placeholder="유저네임" required="required" />
<input type="password" name="password" placeholder="비밀번호" required="required" />
<button>로그인</button>
</form>
(특히 SecurityConfig.java 파일을 보면
.formLogin()
이와 같이 form 로그인 진행된다고 되어 있는데,signin.jsp 파일에서도 보면 로그인을 <form> 태그 안에, 즉 form 로그인이 되도록 되어 있다.)
<웹 사이트 로그인 화면>
2. PostMapping - 로그인 처리
그러면 이제는 username, password 를 입력한 뒤 로그인이 진행되는 과정을 살펴보자
signin.jsp 파일을 보면
action="/auth/signin" method="POST"
이렇게 post 요청되어 있는 것을 알 수 있다. 즉, 로그인 처리는 post 요청이 되어 진행된다.
(로그인 페이지로 이동하는 건 get 요청이지만, username, password 를 입력한 뒤 로그인이 진행되는 것은 post 요청이다.)
보통 post 요청은 insert 할 때 쓰이고, 로그인은 데이터베이스에서 username, password 를 select하는 것이다.
그런데 get 요청이 아니라, post 요청을 한 이유는
select를 위해 get 요청하게 되면 주소창에 사용자가 입력한 username, password 값이 나타나게 되기 때문이다.
이것이 노출되지 않도록 username, password 값들을 body에 넣어서 요청하기 위해 post 방식을 사용한다.
그래서, SecurityConfig.java 파일에서 post 요청 처리를 하는데,
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Bean
public BCryptPasswordEncoder encode() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests()
.antMatchers("/", "/user/**").authenticated()
.anyRequest().permitAll()
.and()
.formLogin()
.loginPage("/auth/signin") // GET
.loginProcessingUrl("/auth/signin") // POST
.defaultSuccessUrl("/");
}
}
위에서 보면
.loginProcessingUrl("/auth/signin") 을 볼 수 있다.
이것이 post 요청하는 것이다.
즉 signin.jsp 파일에서 보면
<form class="login__input" action="/auth/signin" method="POST">
이렇게 post 요청하고 있는 부분을 SecurityConfig.java 파일에서 처리하고 있는 것이다.
즉 사용자가 로그인 화면에서 username, password 값을 입력하고 로그인 버튼을 누르면
username, password 데이터를 가지고 post 요청이 되어 데이터 베이스에 이 사용자 정보가 있는지 확인한 뒤 로그인 처리 된다.
그렇다면 AuthController.java파일에 @Postmapping("/auth/signin") 역시 만들어야 할 것 같지만, 만들지 않아도 된다.
스프링 시큐리티가 알아서 처리해주기 때문에 할 필요 없다.
그렇다면 이와 관련하여 UserDetailsService 에 대해 자세히 알아보자.
3. UserDetailsService(PrincipalDetailsService)
위에서
.loginProcessingUrl("/auth/signin")
이 요청에 의해 사용자의 username, password 데이터를 http body에 넣어서 post 요청하면
스프링의 ioc 컨테이너에 있는 UserDetailsService 라는 객체가 이를 낚아챈다.
그런데, 우리는 UserDetailsService 를 implements 해서 PrincipalDetailsService.java 파일을 만들었다.
<PrincipalDetailsService.java> 파일
@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userEntity = userRepository.findByUsername(username);
if(userEntity == null) {
return null;
}else {
return new PrincipalDetails(userEntity);
}
}
}
이렇게 되면
PrincipalDetailsService 클래스 위에 @Service 애노테이션에 의해 ioc 컨테이너에 PrincipalDetailsService 객체가 생기게 되는데 자신의 부모타입과 같은 UserDetailsService 객체가 이미있기 때문에
ioc 컨테이너에 UserDetailsService 를 없애고 PrincipalDetailsService 객체로 덮어 씌운다.
따라서 PrincipalDetailsService 를 이용해서 로그인이 진행된다.
그러면 PrincipalDetailsService.java 에 있는 loadUserByUsername 메서드를 이용해서 로그인 처리를 하게 되는데,
(여기서 보면 알 수 있듯이, username 은 있는데, password 는 없다. 즉 로그인 처리관련해서 우리는 username만 처리하면 되고, password 는 스프링 시큐리티가 알아서 처리해준다.)
loadUserByUsername 메서드에 보면 userRepository의 findByUsername 을 사용한다.
<UserRespository.java> 파일
public interface UserRepository extends JpaRepository<User,Integer>{
User findByUsername(String username);
}
여기에 사용된 findByUsername 은 jpa의 Named Query 이다.
spring 홈페이지에서 Spring Data reference부분에 들어가보면 Query Creation 부분에서 설명을 볼 수 있을 것이다.
https://docs.spring.io/spring-data/commons/docs/current/reference/html/#repositories.query-methods
Spring Data Commons - Reference Documentation
This section covers the fundamentals of Spring Data object mapping, object creation, field and property access, mutability and immutability. Note, that this section only applies to Spring Data modules that do not use the object mapping of the underlying da
docs.spring.io
즉, jpa 를 통해 데이터베이스에 데이터를 넣거나, 삭제, 가져오기 등을 할 때
그에 맞는 쿼리가 필요한데, 간단한 것들은 Named Query 를 통해 이미 정해져 있는 것을 사용하면 된다.
하지만 복잡한 처리는 직접 SQL문을 활용해서 만들면 된다.
sts(이클립스) 에서도 JpaRepository 인터페이스에 들어가서 다양한 쿼리 메서드를 확인해 볼 수 있다.
어찌됐든, username 을 통해 데이터베이스에 기존 사용자가 있는지 확인하는 쿼리는 findByUsername 메소드를 통해 이미 만들어져 있기 때문에 이를 사용하면 된다.
PrincipalDetailsService.java 파일을 보면
@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userEntity = userRepository.findByUsername(username);
if(userEntity == null) {
return null;
}else {
return new PrincipalDetails(userEntity);
}
}
}
loadUserByUsername 메서드의 리턴타입이 UserDetails 이다.
특히 이 메서드는 @Override 된 것으로, 이미 UserDetailsService 인터페이스에 정의되어 있는 메서드이다.
따라서 loadUserByUsername 메서드의 리턴타입을 UserDetails 으로 해야하는데,
이미 위의 코드에서 PrincipalDetails(userEntity) 라고 되어 있는 부분을 볼 수 있을 것이다.
PrincipalDetails 는 UserDetails 를 implements한 클래스파일이다.
UserDetails 타입으로 리턴하려고 보았더니, UserDetails 인터페이스에 정의되어있는 메서드가
getUsername(), isAccountNonExpired() 등 여러가지가 있다. 이것들을 오버라이드 해야 하는데 메서드가 여러가지라,
보기 불편할 수 있어서,
UserDetails 를 implements한 PrincipalDetails.java 파일을 만들었다.
@Data
public class PrincipalDetails implements UserDetails{
private static final long serialVersionUID = 1L;
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collector = new ArrayList<>();
collector.add(() -> { return user.getRole();});
return collector;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
인터페이스 UserDetails 에 정의되어 있는 여러가지 메서드를 @Overried해서, 리턴값을 정해서 리턴해주었다.
여기에 보면 메서드가
getAuthorities : 사용자 권한설정
getPassword : 사용자 비밀번호 getter
getUsername : 사용자 username getter
isAccountNonExpired : 사용자 계정이 만기가 되지 않았나?
isAccountNonLocked : 사용자 계정이 잠기지 않았나?
isCredentianlsNonExpired : 사용자 자격이 있나?
isEnabled : 아직 사용 가능한가?
관련된 내용들이다.
즉, 이 부분은 세션에 저장될 사용자 정보와 관련있는 내용이다.
PrincipalDetailsService.java에서
return new PrincipalDetails(userEntity);
즉, return 을 UserDetails 타입으로 해야 해서 이를 implements한 PrincipalDetails 클래스를 만들었다고 이야기 했다.
그런데, PrincipalDetailsService 에서 리턴만 잘되면, (return new PrincipalDetails(userEntity) 가 잘 되면)
자동으로 UserDetails 타입의 세션이 만들어진다.
여기서는 PrincipalDetails 로 UserDetails 를 implements했으므로,
PrincipalDetails(userEntity) 가 세션에 저장될 때 PrincipalDetails가 userEntity(user 객체)를 들고 있으므로
세션에 저장된 user 오브젝트를 다른 곳에서 사용가능하게 되는 것이다.
(즉 로그인이 잘 되면 이 세션에 저장된 user 오브젝트 정보를 어디서든 사용 가능)
즉, 사용자 권한, 비밀번호, username을 getter 를 이용해서 사용할 수 있다.
그런데 그러려면 이때 메서드 isAccountNonExpired, isAccountNonLocked, isCredentianlsNonExpired, isEnabled
return true 가 되어야 한다. 그렇지 않으면 로그인이 안 된다.
즉, 사용자 계정이 만기되지 않고, 잠기지 않고, 사용자 자격도 있고, 사용 가능해야 한다.
그래서 PrincipalDetails 클래스에 보면 모두 리턴 값이 true 이다.
그래서 정리하자면,
PrincipalDetails 에 private User user; 로 user 객체 변수를 두어서,
이 사용자에 대한 계정 만기, 계정이 잠겨 있나, 사용자 자격, 사용 가능 여부를 결졍한 뒤
이 user 정보를 PrincipalDetails 객체로 감싸서 (PrincipalDetails 생성자에 매개변수로 User 변수가 사용됨)
세션에 저장해두고 어디서든 이 사용자 정보를 사용할 수 있게 함.
특히 여기서 보면 권한 부분, getAuthorities() 메서드 부분만 리턴값이 collection 타입이다.
즉, 사용자가 권한을 여러개 가질 수 있기 때문인데, 예를 들어 관리자 권한, 일반 사용자 권한 등 여러개 가질 수 있다.
그리고 이러한 권한을 사용자마다 주기 위해 User.java 파일에 role 변수를 정의해주었다.
메서드 리턴타입이 아래와 같이
Collection<? extends GrantedAuthority>
? 인데, GrantedAuthority 를 상속하고 있으므로, GrantedAuthority 타입으로 하면 큰 고민 없이 정할 수 있다.
즉, 이 메서드에서 return collector 할 것인데,
그 타입을 아래처럼
Collection<GrantedAuthority> collector = new ArrayList<>();
즉, Collection<GrantedAuthority> 로 하였다.
특히, ArrayList 로 사용한 이유는, ArrayList의 부모가 collection 이다.
그래서 사용 가능함.
그리고 오버라이드 하는 부분이 복잡해서 람다식을 사용했다.
만약 User.java 파일을 보고 싶다면 더보기 버튼 클릭
<User.java> 파일
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(length = 100, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
private String website; // 웹 사이트
private String bio; // 자기 소개
@Column(nullable = false)
private String email;
private String phone;
private String gender;
private String role; // 권한
private String profileImageUrl;
private LocalDateTime createDate;
@PrePersist
public void createDate() {
this.createDate = LocalDateTime.now();
}
}
PrincipalDetails 에 보면
private static final long serialVersionUID = 1L;
이와 같은 변수를 볼 수 있다.
serial 번호에 관련된 변수로, 이에 대해 궁금하다면
자바 객체의 직렬화(Serializable, serialVersionUID)
자바 객체의 직렬화(Serializable) 자바에서 입출력에 사용되는 것은 스트림이라는 데이터 통로를 통해 이동했습니다. 하지만 객체는 바이트형이 아니라서 스트림을 통해 파일에 저장하거나 네트
ktko.tistory.com
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=kkson50&logNo=220564273220
[완벽해설] serialVersionUID에 대한 정확한 설명
자바에서 컴파일을 할때, 아래와 같은 워닝이 발생을 하였습니다. 그 원인과 해결방법을 찾아보록 하겠습니...
blog.naver.com
4. 세션 확인 - PrincipalDetails 객체의 위치
위에서 user 객체를 들고 있는 PrincipalDetails 객체가 세션에 저장된다고 했는데,
정확히 어디에 들어가있는지 설명하자면,
사용자 로그인(username, password 입력)
-> /auth/signin (post 요청)
-> PrincipalDetailsService 의 loadUserByUsername 메서드로 데이터베이스에 입력한 username이 있는지 확인
-> 있으면 loadUserByUsername 메서드가 리턴한 PrincipalDetails 를 Authentication 이라는 객체에 집어 넣음
-> 이것을 또 세션 내부의 securitycontextholder 에 넣음
즉, 우리가 로그인한 사용자 정보를 사용하려면(예를 들어 사이트 화면에 사용자 username, 이메일 주소 등을 표시)
세션 내부의 securitycontextholder 안의 Authentication 안의 principaldetails 안의 user 오브젝트를 찾아야 함.
(너무 복잡)
그래서 어노테이션을 사용해서 확인 가능.
@AuthenticationPrincipal : Authentication 객체에 바로 접근 가능
-> @AuthenticationPrincipal PrincipalDetails principalDetails : Authentication 객체 내부의 PrincipalDetails 객체를 바로 찾음
5. 세션 확인 - 직접 확인(애노테이션 사용)
UserController.java 파일의
@GetMapping("/user/{id}/update")
public String update(@PathVariable int id) {
return "user/update";
}
이 메서드, 즉 회원정보 수정 페이지로 이동하는 메서드에서 애노테이션으로 user 오브젝트를 확인해 보겠다.
@GetMapping("/user/{id}/update")
public String update(@PathVariable int id, @AuthenticationPrincipal PrincipalDetails principalDetails) {
System.out.println("세션 정보:" + principalDetails.getUser());
return "user/update";
}
이렇게 입력하면 된다.
그러면 /user/{id}/update 로 이동했을 때, (즉 회원정보 수정페이지로 이동)
sts(이클립스) 콘솔창에 아래와 같은 정보, 즉 세션에 저장된 사용자 정보가 나오게 된다.
(물론 사이트에 로그인한 사용자 정보)
중간에 이미지를 잘라내서 그렇지,
id, username, password, name, website, bio 등
User.java 파일에 저장한 변수들이 다 나온다.
만약 User.java 파일을 보고 싶다면 더보기 버튼 클릭
<User.java 파일>
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(length = 100, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
private String website; // 웹 사이트
private String bio; // 자기 소개
@Column(nullable = false)
private String email;
private String phone;
private String gender;
private String role; // 권한
private String profileImageUrl;
private LocalDateTime createDate;
@PrePersist
public void createDate() {
this.createDate = LocalDateTime.now();
}
}
임의로 UserController.java의 update 메서드에서 실행을 했을 뿐,
다른 어느 메서드에서 실행해 봐도 저렇게 세션 정보 잘 나옴.
6. 세션 확인 - 직접 확인(애노테이션 사용 x)
혹시 애노테이션을 사용하지 않고 직접 세션에 저장된 사용자 정보를 확인해보고 싶다면
아래와 같이
Authentication auth 부분부터
System.out.println("직접 찾은 세션 정보 : )
부분까지 코드를 추가했다.
@GetMapping("/user/{id}/update")
public String update(@PathVariable int id, @AuthenticationPrincipal PrincipalDetails principalDetails) {
System.out.println("세션 정보:" + principalDetails.getUser());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
PrincipalDetails mPrincipalDetails = (PrincipalDetails)auth.getPrincipal();
System.out.println("직접 찾은 세션 정보:"+ mPrincipalDetails.getUser());
return "user/update";
}
sts(이클립스) 콘솔창에 보면 아래와 같이 두 정보를 확인할 수 있다.
윗 줄의 "세션 정보"는 위에서 @AuthenticationPrincipal 애노테이션을 사용해서 찾은 정보이고
아랫 줄의 "직접 찾은 세션 정보" 는 애노테이션 없이 직접 찾은 부분이다.
결과적으로 똑같은 값들을 나타내고 있다.
참고 자료 : 이지업 강의 사이트 "스프링부트 SNS프로젝트 - 포토그램 만들기"