이번시간에는 로그인을 해보자. 우리는 github로 로그인을 할텐데 페이스북이나 트위터도 비슷할 듯 하다.

우리는 인증서버는 github에 넘기고 리소스만 관리하며 된다. 일단 시큐리티 관련해서 디펜더시를 받자.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>

spring security는 그렇게 간단하지만은 않다. 그래도 Spring boot를 쓰면 기존에 어려웠던 설정들이 조금이나마 간단해 진 것을 알 수 있다. 위와 같이 디펜더시를 받았다면 yaml이나 properties 파일 설정을 해야 한다.

security.oauth2.client.client-id=xxxxxxxxxxxxxxxxx
security.oauth2.client.client-secret=xxxxxxxxxxxxxxxxxxxxx

security.oauth2.client.access-token-uri=https://github.com/login/oauth/access_token
security.oauth2.client.user-authorization-uri=https://github.com/login/oauth/authorize
security.oauth2.client.client-authentication-scheme=form
security.oauth2.resource.user-info-uri=https://api.github.com/user

위의 설정은 github의 client-id와 client-secret을 적어주면 된다. access-token-uri는 토큰 발급 url을 입력하고 authorization-uri는 인증할 url을 작성하면 된다. client-authentication-scheme은 인증을 할때 form 형태로 한다는 것이고 user-info-uri는 해당 api의 유저 정보를 작성하면 된다.
일단 나머지는 위와 같이 그대로 입력해야 되지만 client-id와 client-secret은 github가서 받아와야 한다. 우리는 개발자이니 github 계정이 있다고 가정하자. 만약 없다면 아주 쉽게 계정을 만들수 있으니 만들자. 그게 싫다면 페이스북이나 트위터를 사용해도 된다.
https://github.com/settings/profile settings 에가서 OAuth applications 메뉴를 들어가자.

이미지가 안보이네.. 갑자기 나중에 수정.. http://blog.wonwoo.ml/wordPress/1288 여기서는 이미지 확인 가능

8-blog1
그런 후에 상단에 보면 Register a new application 버튼을 눌러 어플리케이션을 등록하자.

8-blog2

여기서 중요한것은 callback url이다. 현재 로컬로 개발하니 localhost:8080으로 입력했다. 만약 이게 실제 callback될 url과 다르다면 인증처리가 잘 되지 않을 것이다. 입력을 다 하고 저장을 했다면 client-id와 client-secret이 보일 것이면 위의 properties 파일에 입력해 두자.

우리는 유저 정보를 저장할 테이블도 필요하다. 다음과 같이 만들자.

@Entity
public class User implements Serializable {
  @GeneratedValue
  @Id
  private Long id;

  private String email;

  private String name;

  private String github;

  private String avatarUrl;

  @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
  private List<Post> post = new ArrayList<>();

  @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
  private List<Comment> comments = new ArrayList<>();

  //기타 getter setter hashcode toString

github에는 더 많은 정보가 있지만 우리에게 필요한건 이정도면 충분하다. 어떤 유저가 포스팅을 하고 댓글을 달았는지 저장하기 위해 post와 comments도 추가 했다. 그럼 포스트엔티티와 댓글엔티티도 추가해주자.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "USER_ID")
private User user;

포스트와 댓글 엔티티 둘다 위와 같이 넣어주면 된다.
그럼 이제 스프링 시큐리티 설정을 살펴보자.

@Configuration
@EnableOAuth2Sso
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
      .antMatchers(HttpMethod.GET, "/posts/new").hasRole("ADMIN")
      .antMatchers(HttpMethod.GET, "/posts/{id}").permitAll()
      .antMatchers("/posts/**").hasRole("ADMIN")
      .antMatchers("/categories/**").hasRole("ADMIN")
      .antMatchers("/", "/js/**", "/vendor/**", "/codemirror/**", "/markdown/**", "/login/**", "/css/**", "/img/**", "/webjars/**").permitAll()
      .anyRequest().authenticated()
      .and()
      .csrf()
      .and()
      .formLogin()
      .loginPage("/login")
      .permitAll()
      .and()
      .logout()
      .logoutSuccessUrl("/")
      .permitAll()
      .and()
      .headers()
      .frameOptions().sameOrigin();
  }

  @Bean
  public AuthoritiesExtractor authoritiesExtractor() {
    return map -> {
      String username = (String) map.get("login");
      if ("wonwoo".contains(username)) {
        return AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER,ROLE_ADMIN");
      } else {
        return AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER");
      }
    };
  }

  @Bean
  public PrincipalExtractor principalExtractor(GithubClient githubClient, UserRepository userRepository) {
    return map -> {
      String githubLogin = (String) map.get("login");
      User loginUser = userRepository.findByGithub(githubLogin);
      if (loginUser == null) {
        logger.info("Initialize user with githubId {}", githubLogin);
        GithubUser user = githubClient.getUser(githubLogin);
        loginUser = new User(user.getEmail(), user.getName(), githubLogin, user.getAvatar());
        userRepository.save(loginUser);
      }
      return loginUser;
    };
  }
}

url에 대한 권한을 부여하며 github에 로그인이 되었을 경우에 map에 user정보가 담겨서 오는데 그중 login(여기서 login은 username을 말한다.)이라는 키를 가져와 디비에 조회한 후 없으면 데이터베이스에 넣는다. 또한 권한도 부여해야 되므로 name이 wonwoo(필자의 github username 만약 사용한다면 자신의 아이디로 바꿔주자)이면 관리자 권한 그 외 로그인한 사용자들은 user 권한을 주었다. PrincipalExtractor 인터페이스는 Spring boot 1.4 에 추가 된 인터페이스다. 기본적인 구현체는 FixedPrincipalExtractor 클래스를 사용한다.
여기선 굳이 github서버까지 갈 필요 없다. 이미 map에 담겨져 오는게 github의 user정보를 가져오기 때문에 map에 있는 user정보를 꺼내서 사용해도 된다. 실질적으로 GithubUser user = githubClient.getUser(githubLogin); 코드는 필요 없다.
나중에 github api를 간단하게 사용하기 위해 만들었다.

@Service
@RequiredArgsConstructor
public class GithubClient {

  private final RestTemplate restTemplate;

  private final static String GIT_HUB_URL = "https://api.github.com";

  @Cacheable("github.user")
  public GithubUser getUser(String githubId) {
    return invoke(createRequestEntity(
      String.format(GIT_HUB_URL + "/users/%s", githubId)), GithubUser.class).getBody();
  }
  private  <T> ResponseEntity<T> invoke(RequestEntity<?> request, Class<T> type) {
     //..기타 생략
  }
  private RequestEntity<?> createRequestEntity(String url) {
   //..기타 생략
  }
}

나머지 UserRepository와 GithubUser는 생략하자 올라간 소스를 보자. 간단해서 제외 시켰다.
일단 github로그인은 완성 되었다. 로그인과 로그아웃 버튼을 만들자.

<li>
    <a sec:authorize="!hasRole('ROLE_USER')" th:href="@{/login}"
       role="button">Login</a>
</li>
<li sec:authorize="hasRole('ROLE_USER')">
    <form th:action="@{/logout}" method="post" role="logout">
        <button type="submit" class="btn" style=" padding:18px; color: #fff; background-color: rgba( 255, 255, 255, 0.0 );">Sign Out</button>
    </form>
</li>

기존 메뉴 항목에 위와 같이 추가 하자. sec:authorize라는 네임스페이스가 존재한다. 저 네임스페이스를 사용하려면 아래와 같이 디펜더시를 받아야 한다.

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>

예전에 이것때문에 2.1을 사용한다고 했는데 필자가 잘못한거 같다. 3.0도 잘되는 거 같다. 귀찮아서 그냥 대충 했더니.. 아마도 필자가 그냥 사용해서 그런거 같다. thymeleaf 3을 사용한다면 thymeleaf-extras-springsecurity4 버전도 3.0.0으로 사용해야 되는거 같다. spring boot의 기본적인 버전은 2.1.2 버전이다.

서버를 시작한다음 http://localhost:8080에 접속해보자. 상단에 있는 login버튼을 눌러보자. 아래와 같이 나온다면 성공이다.

8-blog3

한번 자신의 아이디로 로그인을 해보자. 로그인을 하면 권한을 부여할 거냐고 묻는다. 권한을 부여한다고 하면 로그인이 성공적으로 될 것이다.

성공적으로 되었다면 sec:authorize 네임스페이스를 이용해서 권한에 맞게 버튼을 출력해보자. post는 작성하는 사람은 관리자만 작성할 수 있고 댓글 같은 경우에는 관리자와 유저 모두 작성가능하다고 가정하자. 물론 로그인을 하지 않은 상태에서는 댓글 또한 입력할 수 없다.

index.html에 보면 write 버튼이 있다. 아래와 같이 수정하자.

<ul class="pager" sec:authorize="hasRole('ROLE_ADMIN')">
    <li class="next">
        <a th:href="@{/posts/new}">write</a>
    </li>
</ul>

권한이 관리자 일 경우에만 버튼이 출력된다. 수정 또한 관리자만 수정이 가능하다. 수정 화면에서도 다음과 같이 수정하자.

<ul class="pager" sec:authorize="hasRole('ROLE_ADMIN')">
    <li class="next">
        <a th:href="@{'/posts/edit/' + ${post.id}}">edit</a>
    </li>
</ul>

수정 버튼 또한 관리자만 보일 것이다. 댓글도 마찬가지로 USER 이상의 권한만 접근 할 수 있도록 하자.

<div class="well" sec:authorize="hasRole('ROLE_USER')" th:classappend="(${#fields.hasErrors('content')}? ' has-error')">
//생략
</div>

테스트를 해봐도 잘된다. 하지만 post 혹은 comment를 등록할 때 user정보도 함께 넣어줘야 한다. 물론 흔히 아는 시큐리티의 static한 메서드를 사용해도 되지만 SecurityContextHolder.getContext().getAuthentication(); 우리는 Spring boot 1.4에 추가된 어노테이션을 사용할 예정이다. 아주 간단하게 사용가능하다. 해당 컨트롤러에 @AuthenticationPrincipal User user AuthenticationPrincipal 어노테이션만 작성해주면된다. 이것 또한 HandlerMethodArgumentResolver 인터페이스의 구현체다. HandlerMethodArgumentResolver는 예전에 한번 포스팅한 기억이 있다.
그럼 post와 comment에 @AuthenticationPrincipal 어노테이션을 추가하자.

public String createPost(//생략... , @AuthenticationPrincipal User user) {
  //...
}

public String modifyPost(//생략 ... , @AuthenticationPrincipal User user) {
 //...
}

public String createComment(//생략.. , @AuthenticationPrincipal User user) {
// ....
}

user 정보도 post와 comment엔티티에 같이 넣어서 데이터베이스에 넣자. 그래야 해당 외래키의 값이 들어 간다.
그렇게 중요하지 않은 뷰 작업들은 생략했다. 각자 원하는 방법으로 커스터마이징 해보도록 하자!!

우리는 얼추 나쁘지 않게 블로그를 완성했다. 물론 기능적으로도 부족한면이 많고 설명 또한 부족한게 많다. 끝으로 갈 수록 대충한거 같은 느낌이다. ㅠㅠㅠ

이제 코드는 거의 수정할것이 없을 듯하다. 다음시간에는 마지막으로 빌드 배포를 해보도록하자.
오늘까지의 소스는 여기에 있다.

아 그리고 몇일 전에 spring boot github에 이슈를 등록 했었다. 내용은 즉 이런거다. 캐시 설정중 카페인 캐시를 제외한 나머지는 캐시 bean name은 모두 동일하게 cacheManager 이지만 카페인 캐시경우에만 caffeineCacheManager 라는 bean name을 가지고 있었다.
개발하다가 어디선가 cacheManager 계속 찾길래 소스를 까보다가 나온거다.
그래서 이슈로 등록을 했는데 대답도 해주고 Spring boot 1.5에 변경 된다고 했다. 지금은 1.5에 머지된 상태이다.
그닥 중요한 내용은 아니지만 그래도 내 생에 첫 spring에 기여(?)한 이슈이다.
https://github.com/spring-projects/spring-boot/commit/7dfa3a8c8398db26a7bb22abc237b173a9e158aa

이런거 말고 소스를 기여 했으면 더 좋을 텐데 말이다. ㅋㅋㅋㅋ

  1. [spring-boot] 블로그를 만들자 (1)

  2. [spring-boot] 블로그를 만들자. (2) JPA

  3. [spring-boot] 블로그를 만들자. (3) Category 와 Comment

  4. [spring-boot] 블로그를 만들자. (4) thymeleaf

  5. [spring-boot] 블로그를 만들자. (5) markdown, catetory

  6. [spring-boot] 블로그를 만들자. (6) 댓글과 Navigation

  7. [spring-boot] 블로그를 만들자. (7) 캐시와 에러페이지

  8. [spring-boot] 블로그를 만들자. (8) GitHub login

  9. [spring-boot] 블로그를 만들자. (9) CI 와 배포