Skip to content

[최재량] 프리코스 미션 제출합니다. #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,62 @@
# spring-security-authentication


아이디와 비밀번호를 기반으로 로그인 기능을 구현하고 <br>
Basic 인증을 사용하여 사용자를 식별할 수 있도록 스프링 프레임워크를 사용하여 웹 앱으로 구현한다.

- ``Spring security``의 내부 구조를 분석해 직접 구현 (단, ``Filter`` 대신 ``Interceptor`` 활용)

---
# 구현 요구 사항

1. 아이디와 비밀번호 기반 로그인 인증 구현
+ 로그인 요청 시 사용자가 입력한 아이디와 패스워드를 확인하여 인증한다.
+ 로그인 성공 시 ``Session`` 을 사용하여 인증 정보를 저장한다.


2. Basic 인증 구현
+ 사용자 목록 조회 기능 (인증 진행 후 인가 진행)
- 인증 : Basic 인증을 사용하여 사용자를 식별한다.
+ 이를 위해 요청의 **Authorization 헤더**에서 Basic 인증 정보를 추출 후 decode 하여 인증을 처리한다.
+ 인증 성공 시 ``Session``을 사용하여 인증 정보를 저장한다.
+ (다만, 인가를 위한 인증 및 권한 정보는 ThreadLocal 에 저장하여 활용)
- 인가 : ``Member``로 등록되어 있는, 인증된 사용자만 가능하도록 한다.
+ 인증 ``Interceptor``가 통과되면 인가 ``Interceptor`` 진행
+ ThreadLocal 에서 조회하여 인증 정보가 있으면 인가


3. 인터셉터 분리
+ ``HandlerInterceptor``를 사용하여 인증 관련 로직을 ``Controller``에서 분리한다.
+ 앞서 구현한 두 인증 방식(아이디 비밀번호 로그인 방식과 Basic 인증 방식) 모두 인터셉터에서 처리되도록 구현한다.
+ 가급적이면 하나의 인터셉터는 하나의 작업만 수행하도록 설계한다.
+ 아이디/패스워드 기반 Authentication ``Interceptor``
+ Basic 인증 기반 Authentication ``Interceptor``
+ Authorization ``Interceptor``



4. 인증 로직과 서비스 로직 간의 패키지 분리
+ **서비스 코드와 인증 코드를 명확히 분리**하여 관리하도록 한다.
+ 서비스 관련 코드는 ``app`` 패키지에 위치시키고, 인증 관련 코드는 ``security`` 패키지에 위치시킨다.
+ 리팩터링 과정에서 패키지 간의 양방향 참조가 발생한다면 단방향 참조로 리팩터링한다.
+ ``app`` 패키지는 ``security`` 패키지에 의존할 수 있지만, **``security`` 패키지는 ``app`` 패키지에 의존하지 않도록** 한다.
+ 인증 관련 작업은 ``security`` 패키지에서 전담하도록 설계하여, 서비스 로직이 인증 세부 사항에 의존하지 않게 만든다.

```
패키지 간 의존성을 최소화하고, 변경에 강한 구조를 만드는 목적.
security 패키지를 독립적이고 재사용 가능하게 설계하려면, 직접적인 의존성을 피하기 위해 인터페이스를 구현하게 한다. (DIP)
```




<br>

---
# API 정의
### 로그인
- /login [POST] 아이디와 비밀번호를 확인하여 인증. (인증 후 Session에 인증 정보 저장)

### 사용자 조회
- /member [GET] 사용자 목록 조회. (단, 인증 성공 후 인증 정보가 있을 경우만 인가)

10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// implementation 'org.springframework.boot:spring-boot-starter-security'
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {"nextstep.app", "nextstep.util"})
public class SecurityAuthenticationApplication {

public static void main(String[] args) {
SpringApplication.run(SecurityAuthenticationApplication.class, args);
}

}
19 changes: 19 additions & 0 deletions src/main/java/nextstep/app/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package nextstep.app;

import nextstep.security.web.authentication.BasicAuthenticationInterceptor;
import nextstep.security.web.authentication.UsernamePasswordAuthenticationInterceptor;
import nextstep.security.web.authorization.AuthorizationInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UsernamePasswordAuthenticationInterceptor()).addPathPatterns("/login");
// /members 경로는 인증 후 인가 처리
registry.addInterceptor(new BasicAuthenticationInterceptor()).addPathPatterns("/members");
registry.addInterceptor(new AuthorizationInterceptor()).addPathPatterns("/members");
}
}
29 changes: 29 additions & 0 deletions src/main/java/nextstep/app/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package nextstep.app.service;

import lombok.RequiredArgsConstructor;
import nextstep.app.domain.Member;
import nextstep.app.domain.MemberRepository;
import nextstep.security.core.userdetails.User;
import nextstep.security.core.userdetails.UserDetails;
import nextstep.security.core.userdetails.UserDetailsService;
import nextstep.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;


@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException(username));

return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles("MEMBER")
.build();
}
}
16 changes: 3 additions & 13 deletions src/main/java/nextstep/app/ui/LoginController.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package nextstep.app.ui;

import nextstep.app.domain.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -10,23 +10,13 @@
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Slf4j
@RestController
public class LoginController {
public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT";

private final MemberRepository memberRepository;

public LoginController(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}

@PostMapping("/login")
public ResponseEntity<Void> login(HttpServletRequest request, HttpSession session) {
log.info("Login request received");
return ResponseEntity.ok().build();
}

@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Void> handleAuthenticationException() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
6 changes: 2 additions & 4 deletions src/main/java/nextstep/app/ui/MemberController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package nextstep.app.ui;

import lombok.RequiredArgsConstructor;
import nextstep.app.domain.Member;
import nextstep.app.domain.MemberRepository;
import org.springframework.http.ResponseEntity;
Expand All @@ -9,14 +10,11 @@
import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

private final MemberRepository memberRepository;

public MemberController(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}

@GetMapping("/members")
public ResponseEntity<List<Member>> list() {
List<Member> members = memberRepository.findAll();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package nextstep.security.authentication;

import nextstep.security.core.AuthenticationException;

public class AuthenticationCredentialsNotFoundException extends AuthenticationException {
public AuthenticationCredentialsNotFoundException(String msg) {
super(msg);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.security.authentication;

import nextstep.security.core.Authentication;
import nextstep.security.core.AuthenticationException;

public class AuthenticationManager {
private final AuthenticationProvider authenticationProvider = new AuthenticationProvider();

public Authentication authenticate(Authentication authentication) throws AuthenticationException{
return authenticationProvider.authenticate(authentication);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package nextstep.security.authentication;

import nextstep.util.ApplicationContextProvider;
import nextstep.security.core.Authentication;
import nextstep.security.core.AuthenticationException;
import nextstep.security.core.userdetails.UserDetails;
import nextstep.security.core.userdetails.UserDetailsService;

import java.util.Objects;

public class AuthenticationProvider {

private final UserDetailsService userDetailsService;

public AuthenticationProvider() {
this.userDetailsService = ApplicationContextProvider.getApplicationContext().getBean(UserDetailsService.class);
}

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 사용자 정보 load
UserDetails loadedUser = retrieveUser(authentication.getPrincipal());

// 패스워드 체크
authenticationChecks(authentication, loadedUser);

// 인증 정보 생성
return createSuccessAuthentication(loadedUser);
}


private UserDetails retrieveUser(Object username) {
return userDetailsService.loadUserByUsername((String) username);
}

private void authenticationChecks(Authentication authentication, UserDetails loadedUser) {
if (!Objects.equals(authentication.getCredentials().toString(), loadedUser.getPassword())) {
throw new BadCredentialsException("Bad credentials");
}
}

private Authentication createSuccessAuthentication(UserDetails user) {
Authentication authentication = new Authentication(user, null, user.getAuthorities());
authentication.setAuthenticated(true);
return authentication;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package nextstep.security.authentication;

import nextstep.security.core.AuthenticationException;

public class BadCredentialsException extends AuthenticationException {
public BadCredentialsException(String msg) {
super(msg);
}

public BadCredentialsException(String msg, Throwable cause) {
super(msg, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.security.authorization;

/**
* Thrown if an Authentication object does not hold a required authority.
*/
public class AccessDeniedException extends RuntimeException {

public AccessDeniedException(String msg) {
super(msg);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package nextstep.security.authorization;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class AuthorizationDecision {
private final boolean granted;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package nextstep.security.authorization;

import nextstep.security.core.Authentication;

import javax.servlet.http.HttpServletRequest;
import java.util.function.Supplier;

public class AuthorizationManager {
public AuthorizationDecision check(Supplier<Authentication> authentication, HttpServletRequest request) {
boolean granted = isGranted(authentication.get());
return new AuthorizationDecision(granted);
}

private boolean isGranted(Authentication authentication) {
return authentication != null && authentication.isAuthenticated();
}
}
16 changes: 16 additions & 0 deletions src/main/java/nextstep/security/context/SecurityContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package nextstep.security.context;


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nextstep.security.core.Authentication;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class SecurityContext {
private Authentication authentication;
}
34 changes: 34 additions & 0 deletions src/main/java/nextstep/security/context/SecurityContextHolder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package nextstep.security.context;

import org.springframework.util.Assert;

import java.util.function.Supplier;

public class SecurityContextHolder {
private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();

public static SecurityContext getContext() {
Supplier<SecurityContext> result = contextHolder.get();
if (result == null) {
SecurityContext context = createEmptyContext();
result = () -> context;
contextHolder.set(result);
}

return result.get();
}

public static void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(() -> context); //map.set(this, value);
}

public static void clearContext() {
contextHolder.remove();
}

private static SecurityContext createEmptyContext() {
return new SecurityContext();
}
}

Loading