Spring/코드 뜯어보기

[Spring Boot] Principal 은 어디서 username을 가져오는가?

김yejin 2022. 8. 26. 23:30

 

테스트 수정 - 질문,답변 테스트 전, Member 추가

단순히 createSampleData 안에 MemberServieTests.createSampleData() 를 한번 실행하였다.

강사님 코드와 비교하였을 때에도 동일하였다.

public static int createSampleData(MemberService memberService, QuestionRepository questionRepository) throws SignupUsernameDuplicatedException, SignupEmailDuplicatedException {
  MemberServiceTests.createSampleData(memberService);

  member=memberService.findByUsername("user1");
  Question q1 = new Question();
//	... 생략 ...
  return q2.getId();
}

 

질문 - 작성자 추가

파라미터로 스프링 시큐리티가 내부적으로 생성한 Principal 객체를 받아와서 해당 객체로부터 로그인한 Member 의 username정보를 받아옴으로써, 질문 엔티티 자체에 author 작성자를 추가하였다.

강사님의 코드와 차이가 없다. 나는 debug 로그를 찍어서 어디서 어떻게 principal을 받아오는지를 확인해보았는데, 자세한 내용은 아래에 Principal 은 어디서 username을 가져오는가? 에 설명하였다.

@PostMapping("/question")
public String create(@Valid QuestionCreateForm questionCreateForm, BindingResult bindingResult, Principal principal){

    if (bindingResult.hasErrors()) {
        return "question_form";
    }

    Optional<Integer> oid;

    try {
        log.debug("username : "+ principal.getName());
        log.debug("member : "+ memberService.findByUsername(principal.getName()));
        oid = questionService.create(
                questionCreateForm.getSubject(),
                questionCreateForm.getContent(),
                memberService.findByUsername(principal.getName())
        );
    } catch (DataIntegrityViolationException e) {
        e.printStackTrace();
        bindingResult.reject("createQuestionFailed", "이미 등록된 질문입니다.");
        return "question_form";
    } catch (Exception e) {
        e.printStackTrace();
        bindingResult.reject("createQuestionFailed", e.getMessage());
        return "question_form";
    }

    int id=oid.orElseThrow(
            () -> {
                return new RuntimeException("질문을 등록할 수 없습니다.");
            }
    );

    return "redirect:/questions/%d".formatted(id);
}
}

 

 

🤔 Principal 은 어디서 username을 가져오는가?

 

질문을 등록할 때, debug 를 돌려보면,

매개변수로 받는 principal 값은 UsernamePasswordAuthenticationToken 으로부터 얻어오는 값임을 알 수 있다.

// princiapl 이 가진 값
UsernamePasswordAuthenticationToken 
[
Principal=org.springframework.security.core.userdetails.User 
[Username=yejin, Password=[PROTECTED], 
Enabled=true, AccountNonExpired=true, 
credentialsNonExpired=true, 
AccountNonLocked=true, 
Granted Authorities=[ROLE_MEMBER]], 
Credentials=[PROTECTED], 
Authenticated=true, 
Details=WebAuthenticationDetails [RemoteIpAddress=192.168.0.17, SessionId=8B26BC497243BD232074B4970157B702],
Granted Authorities=[ROLE_MEMBER]
]

UsernamePasswordAuthenticationToken 은 무엇인가?

Authentication , 인증을 진행할 때, 실제 인증이 구현되어있는 클래스 이다.

실제 UsernamePasswordAuthenticationToken 클래스 를 보면, filed 값으로 Object principal를 가지고 있다.

/**
 * An {@link org.springframework.security.core.Authentication} implementation that is
 * designed for simple presentation of a username and password.
 * <p>
 * The <code>principal</code> and <code>credentials</code> should be set with an
 * <code>Object</code> that provides the respective property via its
 * <code>Object.toString()</code> method. The simplest such <code>Object</code> to use is
 * <code>String</code>.
 *
 * @author Ben Alex
 * @author Norbert Nowak
 */
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final Object principal;

	private Object credentials;

즉, 스프링 내부적으로

로그인을 할 때, principal 객체가 생성되고, 생성되어있는 principal 객체를 인증과정에서 매개변수로 받음으로써, 작성자가 누구인지 확인이 가능하게 되는 것이다.

로그인 할때, debug를 찍어보면

 

UsernamePasswordAuthenticationFilter 에서 먼저

username와 password을 받아서 UsernamePasswordAuthenticationToken을 생성한다.

 

UsernamePasswordAuthenticationToken.*unauthenticated 가 호출되는데, 현재 별도의 Authenticated 를 넣어놓지 않았기 때문에 unauthenticated() 가 실행된다.*

 

그 다음 생성자를 통해 UsernamePasswordAuthenticationToken 객체가 생성된다.

객체의 principal (”yejin”) 과 credentials (”1234) 에 아이디와 패스워드 값이 저장된다.

 

그 다음 다시 필터로 돌아와서

현재 request에 생성한 토큰 객체(UsernamePasswordAuthenticationToken)를 설정하고,

AuthenticationManager 로 부터 해당 요청을 인증받은 값을 리턴한다.

UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
		password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);

 

전체 UsernamePasswordAuthenticationFilter.attemptAuthentication()

@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

 

그다음 Provider 에서 authentication 이 진행된다.

DaoAuthenticationProvider 의 createSuccessAuthenticaion() 을 보면, DB에서 조회한 인코딩된 pasword 값과 비교하기 위해서 password를 인코딩하여 전달한다.

 

인코딩된 password 값과 DB에서 조회하여 가져온 password 값이 일치하는지 authenticate 과정을 거친 후 createSuccessAuthentication을 생성한다.

 

( 로그인 할때, 권한을 생성하도록 설정을 하여서 authorites를 가져오기 위해 UsernamePasswordAuthenticationToken 에서 authorities를 추가하는 과정이 있는데, 이부분은 생략)

 

따라서 인증이 완료되면 Authenticated user 라는 디버그 로그를 남기고 provider에 리턴한다.

다시 provider에서 리턴으로 받은 authRequest가 인증되었는지를 필터에게 전달하고

필터는 이전의 previous url을 검증하여 redirection 시킨다.

 

java.security 에서 제공하는 인터페이스 Principal

스프링 시큐리티에서 생성하는 Principal 객체를 해당 클래스로 받아, getName() 메소드를 이용하여 로그인한 유저의 이름을 받아올 수 있다.

/**
 * This interface represents the abstract notion of a principal, which
 * can be used to represent any entity, such as an individual, a
 * corporation, and a login id.
 *
 * @see java.security.cert.X509Certificate
 *
 * @author Li Gong
 * @since 1.1
 */
public interface Principal {
...
/**
 * Returns the name of this principal.
 *
 * @return the name of this principal.
 */
public String getName();

 

답변 - 작성자 추가

질문과 동일하게 Principal principal request 파라미터로 추가하여 해당 princiapl.getName() 을 가지는 member를 찾아 Answer 생성 로직에 추가하였다.

강사님 코드와 비교하였을 때도 동일하였으나, bindingResult에서 error을 넘겨줄때, question attribute를 넘기지 않았던 실수를 발견해서 수정하였다.

→ 왜 question attribute가 필요할까? question_detail로 넘어가기 때문이다. (detail뷰에는 각각의 quesion이라는 attribute 값을 기준으로 html이 구성되어 있다.)

@PostMapping("/{id}")
public String create(@PathVariable int id, Model model, @Valid AnswerCreateForm answerCreateForm, BindingResult bindingResult, Principal principal) {
    Question question= this.questionService.detail(id);
    if (bindingResult.hasErrors()) {
       // model.addAttribute("question", question);
        return "question_detail";
    }
    Member member = memberService.findByUsername(principal.getName());
    this.answerService.create(question,answerCreateForm.getContent(), member);
    return String.format("redirect:/questions/%s",id);
}