[수업 목표]
1. 웹의 인증 및 인가의 개념을 이해한다.
2. 스프링 시큐리티를 이용해 폼 로그인 기능을 구현한다.
3. 스프링 시큐리티 OAuth2를 이용해 소셜 로그인 기능을 구현한다.
4. JWT 로그인 기능을 구현한다.
출처: https://aboutssl.org/authentication-vs-authorization/
예를 들면,
웹에서의 인증 및 인가
쿠키와 세션 모두 HTTP에 상태 정보를 유지(Stateful)하기 위해 사용됨.
즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로
인증 및 인가를 할 수 있게 됨.
1. 클라이언트가 서버에 1번 요청
2. 서버가 세션 ID를 생성하고, 응답 헤더에 전달
- 세션 ID 형태: "SESSIONID = 12A345"
3. 클라이언트가 쿠키를 저장('세션쿠키')
4. 클라이언트가 서버에 2번 요청
- 쿠키값 (세션 ID) 포함하여 요청
5. 서버가 세션 ID를 확인하고, 1번 요청과 같은 클라이언트임을 인지
쿠키 | 세션 | |
---|---|---|
설명 | 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일 | 서버에서 일정시간동안 클라이언트 상태를 유지하기 위해 사용 |
저장 위치 | 클라이언트 (웹 브라우저) | 웹 서버 |
Ex | 사이트 팝업의 "오늘 다시보지 않기" 정보 저장 | 로그인 정보 저장 |
만료 시점 | 쿠키 저장 시 만료일시 설정 O (브라우저 종료 시도 유지 가능) | 다음 중 하나가 만족될 경우 만료됨 1. 브라우저 종료 시까지 2. 클라이언트 로그아웃 시까지 3. 서버에 설정한 유지기간까지 해당 클라이언트의 재요청이 없는 경우 |
용량 제한 | 브라우저 별로 다름(크롬 기준) - 하나의 도메인 당 180개 - 하나의 쿠키 당 4KB | 개수 제한 없음 (단, 세션 저장소 크기 이상 저장 X) |
보안 | 취약 (클라이언트에서 쿠키정보 쉽게 변경, 삭제, 가로채기 당할 수 O) | 비교적 안전 (서버에 저장되어 상대적 안전) |
'스프링 시큐리티' 프레임워크는 스프링 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공해 줌으로써 개발의 수고를 덜어줌.
'스프링 프레임워크'가 웹서버 구현에 편의를 제공해 주는 것과 비슷!
이제부터 '나만의 셀렉샵' 서비스에 회원 관리 기능을 추가해보자!
1) '스프링 시큐리티' 프레임워크 추가
// 스프링 시큐리티
implementation 'org.springframework.boot:spring-boot-starter-security'
2) '스프링 시큐리티' 활성화 (기능 동작)
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 어떤 요청이든 '인증'
.anyRequest().authenticated()
.and() // 조건 추가!
// 로그인 기능 허용
.formLogin() // 스프링 Security에서 미리 만들어 놓은 기능
.defaultSuccessUrl("/") // 로그인 성공 시 어디로 이동시킬 지
.permitAll() // 로그인 관련 기능에 대하여 허용
.and()
// 로그아웃 기능 허용
.logout()
.permitAll();
}
}
- configure 함수로 스프링 Security 인증 및 인가 관련 설정 가능
참고) 스프링 시큐리티의 default 로그인 기능
1) 회원 가입 UI 반영
.and()
// 로그인 기능 허용
.formLogin()
.loginPage("/user/login") // 로그인 뷰가 나타남
.defaultSuccessUrl("/")
.failureUrl("/user/login?error") // 로그인 실패했을 때 url
.permitAll()
- WebSecurityConfig에서 알아서 어떤 요청이든 인증하도록 되어있음
- 따라서 알아서 Redirect 해줌
2) UI 이슈 해결
// image 폴더를 login 없이 허용
.antMatchers("/images/**").permitAll()
// css 폴더를 login 없이 허용
.antMatchers("/css/**").permitAll()
1) 회원 테이블 설계
2) 관리자 회원 가입 인가 방법
-> 실제로는 이렇지 않다는 점..
보통 현업에서는
1) '관리자' 권한을 부여할 수 있는 관리자 페이지 구현
2) 승인자에 의한 결재 과정 구현 → 관리자 권한 부여
3) 회원가입 API 구현
1. 회원가입 요청 DTO 구현
2. 회원관리 Controller 구현(+ 회원가입 요청 처리 POST)
3. 회원가입 API Service 구현
4. 회원가입 API Repository 구현
5. UI 연동 테스트
6. 스프링 Security 허용 정책 변경 필요
import org.springframework.context.annotation.Configuration;
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;
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) {
// h2-console 사용에 대한 허용 (CSRF, FrameOptions 무시)
web
.ignoring()
.antMatchers("/h2-console/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 회원 관리 처리 API (POST /user/**) 에 대해 CSRF 무시
http.csrf()
.ignoringAntMatchers("/user/**");
http.authorizeRequests()
// image 폴더를 login 없이 허용
.antMatchers("/images/**").permitAll()
// css 폴더를 login 없이 허용
.antMatchers("/css/**").permitAll()
// 회원 관리 처리 API 전부를 login 없이 허용
.antMatchers("/user/**").permitAll()
// 그 외 어떤 요청이든 '인증'
.anyRequest().authenticated()
.and()
// 로그인 기능
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/")
.failureUrl("/user/login?error")
.permitAll()
.and()
// 로그아웃 기능
.logout()
.permitAll();
}
}
- POST 요청 시에는 CSRF 무시해야 함
1) 패스워드 암호화
'정보통신망법, 개인정보보호법'에 의해 비밀번호는 암호화(Encryption)가 의무!
따라서 아래와 같이 암호화 후 패스워드 저장이 필요
또한 복호화가 불가능한 '일방향' 암호 알고리즘 필요
'일방향' 암호 알고리즘
그럼 사용자가 로그인할 때는 암호화된 패스워드를 기억해야 함? -> NO!!
2) 패스워드 암호화 적용
스프링 Security에서 '권고'하고 있는 'BCrypt 해시함수'를 사용해 패스워드를 암호화하여 DB에 저장하자!
@Bean
public BCryptPasswordEncoder encodePassword() {
return new BCryptPasswordEncoder();
}
[암호화 알고리즘 사용 용도]
(1) 회원 가입 시 패스워드를 암호화하여 저장
(2) 로그인 인증 시 사용
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
...
public void registerUser(SignupRequestDto requestDto) {
...
// 패스워드 암호화
String password = passwordEncoder.encode(requestDto.getPassword());
...
- BCryptPasswordEncoder를 DI.
1) 로그인, 로그아웃 처리 과정
스프링 Security 사용 전
스프링 Security 사용 후
Client의 요청은 모두 스프링 Security 를 거침
스프링 Security 역할
인증/인가
a. 성공 시: Controller 로 Client 요청 전달
b. 실패 시: Controller로 Client 요청 전달되지 않음
[위 그림 설명]
- Authentication Manager가 직접하지 않고
UserDetails Service에게 일을 시킴
- UserDetails가 User을 담고 있음
- 3번에서 pw(평문) 암호화된걸로 비교하는 것도 포함
- 일치하면 세션 생성(일정기간 동안 조회할 필요 X)
- 스프링 Security에서는 Authenticaiton Manager구현되어 있음
- UserDetails는 우리가 조회해야함.
- 스프링 Security에서 구현된 인터페이스로 Service와
UserDetails 구현하면 자동으로 동작하도록 해줌.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
// [로그인 기능]
.formLogin()
// 로그인 View 제공 (GET /user/login)
.loginPage("/user/login")
// 로그인 처리 (POST /user/login)
.loginProcessingUrl("/user/login")
.permitAll();
}
- 위와 같이 설정 시(loginProcessingUrl) "POST /user/login"
로 설정됨
인증 관리자 (Authentication Manager)
a. UserDetailsService 에게 username 을 전달하고 회원 상세 정보를 요청
UserDetailsService
a. 회원 DB 에서 회원 조회
b. 조회된 회원 정보(user)를 UserDetails 로 변환
c. UserDetails 를 "인증 관리자"에게 전달
// a.
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));
// b.
UserDetails userDetails = new UserDetailsImpl(user)
"인증 관리자"가 인증 처리
a. 아래 2개의 username, password 일치 여부 확인
b. password 비교 시
c. 인증 성공 시 -> 세션에 로그인 정보 저장
d. 인증 실패 시 -> Error
2) 로그인, 로그아웃 구현
// 로그인 처리 (POST /user/login)
.loginProcessingUrl("/user/login")
// 로그아웃 처리 URL
.logoutUrl("/user/logout")
DB의 회원 정보 조회 -> 스프링 Security의 "인증 관리자"에게 전달
a. UserDetailsService 구현
(security > UserDetailsServiceImpl)
b. UserDetails 구현
(security > UserDetailsImpl)
// a.
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));
return new UserDetailsImpl(user);
}
3) 회원 로그인 / 로그아웃 UI 처리
@Controller
public class TestController {
@GetMapping("/")
public String test(@AuthenticationPrincipal UserDetailsImpl userDetails) {
}
}
1) 상품 등록 및 조회 (모든 회원)
// a.
http.csrf()
.ignoringAntMatchers("/user/**")
.ignoringAntMatchers("/api/products/**");
// b.
// CSRF protection 을 비활성화
http.csrf().disable();
2) 상품 등록 및 조회 (회원별)
회원 별로 등록된 상품을 조회하도록 해보자!
> updateProduct는 로그인한 사용자의 상품만 있어 userId 필요X
but, 현업에서는 실제 userId 추가하여 이 로그인한 사용자의
product인지 확인(내가 다른 사용자의 상품을 변경할 수도 있음)
1) 상품 조회 (관리자용) 설계
요구사항
Client에서 "일반 회원"과 "관리자" 구분하여 API 호출 필요
프론트 개발자와 작업 방법 논의
a. 관리자인 경우만 -> "admin" id를 가진 <div> 추가
(동적 웹페이지 사용)
b. 프론트 개발자 작업
- "admin" id가 내려오면 관리자로 판단
- 상품 조회 시 역할별로 분리하여 API 호출
2) 상품 조회 (관리자용) 구현
<div>
추가// a.
if (userDetails.getUser().getRole() == UserRoleEnum.ADMIN) {
model.addAttribute("admin_role", true);
}
// b.
<div th:if="${admin_role}" id="admin"></div>
// (관리자용) 등록된 모든 상품 목록 조회
@GetMapping("/api/admin/products")
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
// 모든 상품 조회 (관리자용)
public List<Product> getAllProducts() {
return productRepository.findAll();
}
but, "관리자"용 조회 목록이 다른 사용자 로그인된 상태에서 브라우저에 치기만해도 나옴.
일반인도 접근권한이 있음
1) API 접근 권한 제어 이해
'일반 사용자'는 관리자 페이지에 접속이 인가되지 않아야 함.
public class UserDetailsImpl implements UserDetails {
// ...
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority("ROLE_ADMIN");
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(adminAuthority);
return authorities;
}
}
// (관리자용) 등록된 모든 상품 목록 조회
@Secured("ROLE_ADMIN")
@GetMapping("/api/admin/products")
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured 어노테이션 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
2) 접근 불가 페이지 적용
스프링 Security 설정을 이용해 일반 사용자가 '관리자용 상품조회 API' 에 접속 시도 시 접속 불가 페이지가 뜨도록 구현해보자
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum userRole = user.getRole();
String authority = userRole.getAuthority();
SimpleGrantedAuthority simpleAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleAuthority);
return authorities;
}
참고) HTTP Status Code 403
클라이언트 오류 상태. 서버에 요청이 전달되었지만, 권한 때문에 거절됨
MDN Docs
1) 소셜 로그인 탄생 배경
👉 모든 웹 사이트에서 회원가입 과정을 거치는 것은 사용자에게 부담이 됩니다.
매번 번거로운 회원가입 과정을 수행해야 할 뿐 아니라, 웹 사이트마다 다른 아이디와 비밀번호를 기억해야 합니다.
또한 웹 사이트를 운영하는 측에서도 회원들의 개인정보를 지켜야하는 역할이 부담이 됩니다. 바이러스와 백신의 관계 처럼, 발전하는 해킹 기술을 막기 위해 보안을 강화하는 노력이 지속적으로 필요하기 때문이죠.
이런 문제를 해결하기 위해 OAuth 를 사용한 소셜 로그인이 등장합니다.
2) OAuth 란?
👉 OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준입니다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜 입니다.
OAuth를 사용하는 서비스 제공자는 대표적으로 구글, 페이스북 등이 있습니다. 국내에는 대표적으로 네이버와 카카오가 있죠.
1) 카카오 로그인 사용 승인 받기
1) 카카오 서버에서 인가코드 받기
참고: "인가코드 받기" 메뉴얼
https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={REDIRECT_URI}&response_type=code
kakao developers에서 REST API 키 확인
카카오 로그인 시도
사용자가 카카오 로그인 페이지를 통해 '동의하고 계속하기'를 클릭하면, Redirect URI (callback)로 '인가코드'가 전달 됨!
카카오에서 보내주는 '인가코드' 처리
@GetMapping("/user/kakao/callback")
public String kakaoLogin(@RequestParam String code) {
// authorizedCode: 카카오 서버로부터 받은 인가 코드
userService.kakaoLogin(code);
return "redirect:/";
}
<1> 카카오에서 보내주는 '인가코드'를 받음 ⇒ Controller
Ex) http://localhost:8080/user/kakao/callback?code=zAGh...
<2> '인가코드'를 가지고 카카오 로그인 처리 ⇒ Service
<3> 로그인 성공 시 "/" 으로 redirect ⇒ Controller
2) 카카오 사용자 정보 가져오기
// 1. "인가 코드"로 "액세스 토큰" 요청
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP Body 생성
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/user/kakao/callback");
body.add("code", code);
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
new HttpEntity<>(body, headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String.class
);
// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
String accessToken = jsonNode.get("access_token").asText();
{
"id": 1632335751,
"properties": {
"nickname": "르탄이",
"profile_image": "http://k.kakaocdn.net/...jpg",
"thumbnail_image": "http://k.kakaocdn.net/...jpg"
},
"kakao_account": {
"profile_needs_agreement": false,
"profile": {
"nickname": "르탄이",
"thumbnail_image_url": "http://k.kakaocdn.net/...jpg",
"profile_image_url": "http://k.kakaocdn.net/...jpg"
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "letan@sparta.com"
}
}
1) 카카오 사용자 회원가입 설계
관심 상품 등록을 했을 때 회원 구분이 필요하기 때문에, 카카오 서버에서 받은 사용자 정보를 이용해 회원 가입
현재 회원 (User) 테이블
- id, username, password, email, role
카카오로부터 받은 사용자 정보
- kakaoId, nickname, email
테이블 설계 옵션
회원 (User) 테이블에 적용하자!
컬럼명 | 컬럼타입 | 중복허용 | 설명 | 카카오 사용자 |
---|---|---|---|---|
id | Long | X | 테이블 ID | 테이블 ID |
username | String | X | 회원 아이디 | nickname |
password | String | O | 패스워드 | UUID (랜덤 문자열) |
String | X | 이메일 주소 | ||
role | String | O | 역할 | "USER"로 고정 |
kakaoId | String (Nullable) | X | 카카오 로그인 ID | kakaoId |
패스워드를 UUID : 폼 로그인을 통해서 로그인되지 않도록
1) 카카오 사용자 정보로 회원가입
// DB 에 중복된 Kakao Id 가 있는지 확인
Long kakaoId = kakaoUserInfo.getId();
User kakaoUser = userRepository.findByKakaoId(kakaoId)
.orElse(null);
if (kakaoUser == null) {
// 회원가입
// username: kakao nickname
String nickname = kakaoUserInfo.getNickname();
// password: random UUID
String password = UUID.randomUUID().toString();
String encodedPassword = passwordEncoder.encode(password);
// email: kakao email
String email = kakaoUserInfo.getEmail();
// role: 일반 사용자
UserRoleEnum role = UserRoleEnum.USER;
kakaoUser = new User(nickname, encodedPassword, email, role, kakaoId);
userRepository.save(kakaoUser);
}
2) 강제 로그인 처리
로그인 성공 시,
강제 로그인 처리 방법
// 4. 강제 로그인 처리
UserDetails userDetails = new UserDetailsImpl(kakaoUser);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
2주차 공부하면서 학교에서 배운 네트워크나 DB 관련 내용이 많아 흥미롭게 들었던 것 같다. 추가적으로 JWT 관련 강의도 있었는데 이 부분은 어렵기도 하고 따로 정리해보고 싶어서 다음 게시물에서 정리할 것이다. 아즈아😬
출처: 스파르타코딩클럽
[스파르타코딩클럽] Spring 심화반 - JWT 로그인 구현하기