😀

SpringSecurityとSpringBootでログイン認証と投稿機能を実装する

2021/11/23に公開

#概要
SpringSecurityでDBログイン認証後にユーザーの情報を取得し、投稿した際にユーザー情報も投稿用のテーブルに保存するアプリケーションです。

###環境

  • SpringBoot 2.5.6
  • Java11
  • SpringSecurity
  • MySQL
  • SpringDataJPA
  • Gradle

###作成する機能

  • ユーザー登録
  • ログイン
  • ログアウト
  • 投稿

#実装

##データベース
###usersテーブル

id email name password created_at
int varchar varchar varchar datetime
NOT NULL NOT NULL, UNIQUNESS NOT NULL NOT NULL NOT NULL
PRIMARY KEY

###tweetsテーブル

id text image user_id created_at updated_at
int varchar varchar int datetime datetime
NOT NULL NOT NULL NOT NULL NOT NULL NOT NULL
PRIMARY KEY

##エンティティ
###usersテーブルのエンティティ
usersテーブルのエンティティの実装は以下の通りです。
メールアドレスのみ一意性の制約を設けいています。この制約のバリデーションについては、以下に解説していますので、参照ください。

SpringBootで一意性制約のアノテーションを自作(コピペで完成)

MUser
package com.example.demo.entity;

import java.time.LocalDateTime;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;

import org.hibernate.annotations.CreationTimestamp;

import lombok.Data;

@Entity
@Data
@Table(name = "users", uniqueConstraints = {@UniqueConstraint(columnNames = "email")})
public class MUser {

	@Id
	@Column(name = "id")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer userId;
	
	@Column(unique = true)
	private String email;
	
	@Column(name = "name")
	private String username;
	
	private String password;
	
	@CreationTimestamp
	@Column(name = "created_at")
	private LocalDateTime createTime;
	
	private String role;
	
	
}

###tweetsテーブルのエンティティ
以下がtweetsテーブルのエンティティクラスです。
注目するところはアソシエーションを組んでいるところです。
今回の場合は、usersが親テーブルとなる多対1の関係なので、@ManyToOneをつけています。

また、@JoinColumnでは、name属性にtweetsテーブルのuser_idを指定し、referencedColumnName属性に参照先であるusersテーブルのidを指定しています。

MTweet
package com.example.demo.entity;

import java.time.LocalDateTime;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;

import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import lombok.Data;

@Entity
@Data
@Table(name = "tweets")
public class MTweet {
	
	@Id
	@Column(name = "id")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer tweetId;
	
	private String text;
	
	@Column(name = "image")
	private String imageUrl;
	
	@Column(name = "user_id")
	private Integer userId;
	
	@CreationTimestamp
	@Column(name = "created_at")
	private LocalDateTime createTime;
	
	@UpdateTimestamp
	@Column(name = "updated_at")
	private LocalDateTime updateTime;
	
	@ManyToOne(optional = true)
	@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
	private MUser user;

}

##リポジトリ
リポジトリにはメールアドレスで認証するため、メールアドレスで検索するメソッドを追加してます。

UserRepository
package com.example.demo.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.example.demo.entity.MUser;

@Repository
public interface UserRepository extends JpaRepository<MUser, Integer> {

	 public Optional<MUser> findByEmail(String email);
	 
}

##サービス
参考文献にはありませんが、自分の場合はサービスクラスを作成しました。理由としては、この後の実装で、登録処理の記述をコントローラーでなく、サービスに記述するためです。

ロジックをサービスに書くことでコントローラーの肥大化を避けることができます。

UserService
package com.example.demo.service;

import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.demo.entity.MUser;
import com.example.demo.form.SignupForm;
import com.example.demo.repository.UserRepository;

@Service
public class UserService {
	
	@Autowired
	private UserRepository userRepository;

	public Optional<MUser> findByEmail(String email) {		
		return userRepository.findByEmail(email);
	}
}

##セキュリティ
以下がセキュリティの設定クラスです。
基本、コメントに書いたままですが、重要なのは最下部の認証の設定です。

ここでは、UserDetailsPasswordEncoderで認証するように設定しています。

SecurityConfig
package com.example.demo.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Autowired
	private UserDetailsService userDetailsService;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	/**セキュリティ適用外*/
	@Override
	public void configure(WebSecurity web) throws Exception {
		web
			.ignoring()
				.antMatchers("/webjars/**")
				.antMatchers("/css/**")
				.antMatchers("/js/**");
	}
	
	/**直リンクの設定*/
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		//ログイン不要ページの設定
		http
			.authorizeRequests()
				.antMatchers("/").permitAll()
				.antMatchers("/signup").permitAll()
				.antMatchers("/login").permitAll()
				.anyRequest().authenticated();
		
		//ログイン処理
		http
			.formLogin()
				.loginProcessingUrl("/login")//ログイン処理のパス
				.loginPage("/login")//ログインページの指定
				.failureUrl("/login?error")//ログイン失敗時の遷移先の指定
				.usernameParameter("email")
				.passwordParameter("password")
				.defaultSuccessUrl("/index", true);//ログイン成功時の遷移先の指定
		//ログアウト処理
		http
			.logout()
				.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
				.logoutUrl("/logout")
				.logoutSuccessUrl("/login?logout");
				
		
	}
	
	/**認証の設定*/
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		PasswordEncoder encoder = passwordEncoder();
		
		auth
			.userDetailsService(userDetailsService)
			.passwordEncoder(encoder);
	}

}

##認証・認可に使用するユーザー情報
ここは参考文献にある記事とほぼ同じような実装です。
解説をすると、SpringSecurityが認証・認可に使用するユーザー情報を、リファレンス実装であるorg.springframework.security.core.userdetails.Userを継承して作成しました。

コンストラクタではデータベースから検索したMUserエンティティのインスタンスを受け取り、そのインスタンスのメールアドレス、パスワード、ロールを使って、スーパークラス(org.springframework.security.core.userdetails.User)のコンストラクタを呼び出すという流れです。

ちなみに、org.springframework.security.core.userdetails.Userにはコンストラクタが2つあるようなので、以下に記述しておきます。

今回の実装で使用したのは①の方です。

参考文献の方は、ではboolean型のカラムがあったため、②を使用しています。

User_コンストラクタ①
public User(String username, String password, Collection<? extends GrantedAuthority> authorities)
ユーザーコンストラクタ②
public User(String username, String password, boolean enabled,
    boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked,
    Collection<? extends GrantedAuthority> authorities)
SimpleLoginUser
package com.example.demo.service.impl;

import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import com.example.demo.entity.MUser;

public class SimpleLoginUser extends org.springframework.security.core.userdetails.User {
  // DBより検索したMUserエンティティ
  // アプリケーションから利用されるのでフィールドに定義
  private MUser user;

  /**
   * データベースより検索したuserエンティティよりSpring Securityで使用するユーザー認証情報の
   * インスタンスを生成する
   *
   * @param user userエンティティ
   */
  public SimpleLoginUser(MUser user) {
    super(user.getEmail(), user.getPassword(), convertGrantedAuthorities(user.getRole()));
    this.user = user;
  }

  public MUser getUser() {
    return user;
  }

  /**
   * カンマ区切りのロールをSimpleGrantedAuthorityのコレクションへ変換する
   *
   * @param roles カンマ区切りのロール
   * @return SimpleGrantedAuthorityのコレクション
   */
  static Set<GrantedAuthority> convertGrantedAuthorities(String roles) {
    if (roles == null || roles.isEmpty()) {
      return Collections.emptySet();
    }
    Set<GrantedAuthority> authorities = Stream.of(roles.split(","))
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toSet());
    return authorities;
  }

}

##ユーザー情報の取得
データベースのusersテーブルから、SpringSecurityが認証・認可に使用するユーザー情報(SimpleLoginUser)を取得(生成)するためにUserDetailsServiceインターフェースを実装したサービスクラスを作成する必要があります。

このインターフェースでオーバーライドしないといけないメソッドはloadUserByUsername()です。
メソッド名にByUsernameとありますが、一意の情報を渡さないといけないため、今回はその制約を設けてあるメールアドレスを渡しています。

一意の情報を渡すことで、ユーザーを識別できるため、メールアドレスからMUserエンティティを検索し、Userエンティティを基にユーザー情報(SimpleLoginUser)を生成します。

ちなみに、一意であればユーザー名で検索をかけてもOKです。

SimpleUserDetailsService
package com.example.demo.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.demo.repository.UserRepository;

import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class SimpleUserDetailsService implements UserDetailsService {
	
	@Autowired
	private UserRepository userRepository;

  /**
   * メールアドレスで検索したユーザーのuserエンティティをSimpleLoginUserクラスのインスタンスへ変換する
   *
   * @param email 検索するユーザーのメールアドレス
   * @return メールアドレスで検索できたユーザーのユーザー情報
   * @throws UsernameNotFoundException メールアドレスでユーザーが検索できなかった場合にスローする。
   */
  @Transactional(readOnly = true)
  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    assert(email != null);
    log.debug("loadUserByUsername(email):[{}]", email);
    return userRepository.findByEmail(email)
        .map(SimpleLoginUser::new)
        .orElseThrow(() -> new UsernameNotFoundException("User not found by email:[" + email + "]"));
  }
}

##ユーザー登録
ユーザーのサービスクラスに登録処理(setUser()メソッド)を記述してます。

UserService
package com.example.demo.service;

import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.demo.entity.MUser;
import com.example.demo.form.SignupForm;
import com.example.demo.repository.UserRepository;

@Service
public class UserService {
	
	@Autowired
	private PasswordEncoder encoder;
	
	@Autowired
	private UserRepository userRepository;

	@Transactional
	public void setUser(SignupForm form) {
		MUser user = new MUser();
		user.setUsername(form.getUsername());
		user.setEmail(form.getEmail());
		//パスワードの暗号化
		String rowPassword = form.getPassword();
		user.setPassword(encoder.encode(rowPassword));
		user.setRole("ROLE_GENERAL");
		userRepository.save(user);
	}

	public Optional<MUser> findByEmail(String email) {		
		return userRepository.findByEmail(email);
	}
}

##コントローラー
以下が、ユーザー登録とログインのコントローラーの実装です。
SpringSecurityの場合、ログイン処理はWebSecurityConfig(セキュリティ設定クラス)でロジックを記述できるので、基本は画面遷移の処理のみ記述します。

登録処理ではUserServiceをDIしているため、先程記述した、サービスクラスの登録処理メソッドを呼び出します。

フォームからユーザーネームの入力値を取得しているのは、登録処理後のトップ画面でユーザー名を表示するためです。

UserController
package com.example.demo.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.example.demo.form.SignupForm;
import com.example.demo.service.UserService;
import com.example.demo.service.impl.SimpleLoginUser;

@Controller
@RequestMapping("/")
public class UserController {
	
	@Autowired
	private UserService userService;

    @GetMapping("/signup")
	public String getSignup(@ModelAttribute("user") SignupForm form) {

		return "user/signup";
	}
	
	@PostMapping("/signup")
	public String postSignup(@Validated @ModelAttribute("user") SignupForm form, BindingResult result) {
		if (result.hasErrors()) {
			return "user/signup";
		}
		
		userService.setUser(form);
		
		String username = form.getUsername();
		model.addAttribute("username", username);
		
		return "tweet/index";
	}

	@GetMapping("/login")
	public String getLogin() {

		return "user/login";
	}
	
	@PostMapping("/login")
	public String postLogin() {
		
		return "redirect:/index";
	}

##投稿機能の実装
###サービスクラス
以下が、投稿機能のロジックですが、注意点としては、投稿者のユーザーIDを保存するといった仕様です。

自分はここの実装に苦労し、約1日半かかり実装しました、、、

重要な点は@AuthenticationPrincipalアノテーションです。このアノテーションを付けたパラメータで、認証しているユーザー(ログインユーザー)の情報を受け取ることができます。

ちなみに、このアノテーションの設定の仕方も2通りあります。
①の方が使用するクラスを明示的に示せるのでわかりやすいかと思います。

AuthenticationPrincipal①
@GetMapping(value = "sample")
  public String sample(@AuthenticationPrincipal SimpleLoginUser loginUser) {
    log.info("id:{}, name:{}", loginUser.getUser().getId(), loginUser.getUser().getName());
    return "sample";
  }
AuthenticationPrincipal②
@GetMapping(value = "sample")
  public String sample(@AuthenticationPrincipal(expression = "user") User user) {
    log.info("id:{}, name:{}", user.getId(), user.getName());
    return "sample";
  }
TweetService
package com.example.demo.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Service;

import com.example.demo.entity.MTweet;
import com.example.demo.form.TweetForm;
import com.example.demo.repository.TweetRepository;
import com.example.demo.service.impl.SimpleLoginUser;

@Service
public class TweetService {

	@Autowired
	private TweetRepository tweetRepository;
	
	/**投稿機能*/
	public void setTweet(TweetForm form, @AuthenticationPrincipal SimpleLoginUser loginUser) {
		MTweet tweet = new MTweet();
		tweet.setUserId(loginUser.getUser().getUserId());
		tweet.setText(form.getText());
		tweet.setImageUrl(form.getImageUrl());
		tweetRepository.save(tweet);
	}
	
	/**投稿全取得*/
	public List<MTweet> findAllTweets(){
		return tweetRepository.findAll();
	}
}

###コントローラー
ここは見ての通りという感じですが、@AuthenticationPrincipalで認証ユーザーを取得し、ユーザー名をビューに渡す処理をしています。

また、投稿処理では、サービスロジックを呼び出し投稿完了画面に遷移するようにしています。

TweetController
package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.example.demo.form.TweetForm;
import com.example.demo.service.TweetService;
import com.example.demo.service.impl.SimpleLoginUser;

@Controller
@RequestMapping("/")
public class TweetController {

	@Autowired
	private TweetService tweetService;

	@GetMapping("/new")
	public String getNew(@ModelAttribute("tweetForm") TweetForm form, Model model, @AuthenticationPrincipal SimpleLoginUser loginUser) {
		String name = loginUser.getUser().getUsername();
		model.addAttribute("username", name);

		return "tweet/new";
	}

	@PostMapping("/comfirm")
	public String postNew(@Validated @ModelAttribute("tweetForm") TweetForm form, BindingResult result, Model model, @AuthenticationPrincipal SimpleLoginUser loginUser) {
		String name = loginUser.getUser().getUsername();
		model.addAttribute("username", name);

		if (result.hasErrors()) {
			return "tweet/new";
		}

		tweetService.setTweet(form, loginUser);

		return "tweet/comfirm";
	}
}

###投稿の一覧表示
最後に投稿の一覧表示をするようトップページにあたる部分にTweetServiceで実装した一覧取得のメソッド(findAllTweets())を呼び出し、ビューに渡します。

UserController
package com.example.demo.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.example.demo.entity.MTweet;
import com.example.demo.form.SignupForm;
import com.example.demo.service.TweetService;
import com.example.demo.service.UserService;
import com.example.demo.service.impl.SimpleLoginUser;

@Controller
@RequestMapping("/")
public class UserController {
	
	@Autowired
	private UserService userService;
	
	@Autowired
	private TweetService tweetService;
	
	@GetMapping("/")
	public String getTop(Model model) {
		
		List<MTweet> tweetList = tweetService.findAllTweets();
		model.addAttribute("tweetList", tweetList);
		
		return "top";
	}
	
	@GetMapping("/index")
	public String getIndex(Model model, @AuthenticationPrincipal SimpleLoginUser loginUser) {
		String name = loginUser.getUser().getUsername();
		model.addAttribute("username", name);
		
		List<MTweet> tweetList = tweetService.findAllTweets();
		model.addAttribute("tweetList", tweetList);
		
        return "tweet/index";
	}
	
	@GetMapping("/signup")
	public String getSignup(@ModelAttribute("user") SignupForm form) {
		return "user/signup";
	}
	
	@PostMapping("/signup")
	public String postSignup(@Validated @ModelAttribute("user") SignupForm form, BindingResult result, Model model) {
		if (result.hasErrors()) {
			return "user/signup";
		}
		
		userService.setUser(form);
		
		String username = form.getUsername();
		model.addAttribute("username", username);
		
		List<MTweet> tweetList = tweetService.findAllTweets();
		model.addAttribute("tweetList", tweetList);
		
		return "tweet/index";
	}

	@GetMapping("/login")
	public String getLogin() {
		return "user/login";
	}
	
	@PostMapping("/login")
	public String postLogin(Model model) {
		List<MTweet> tweetList = tweetService.findAllTweets();
		model.addAttribute("tweetList", tweetList);
		
		return "redirect:/index";
	}
	
}

###ビュー
リスト形式でビューに渡すため、th:each属性を用いて、繰り返しの表示をするようにしています。

アソシエーションを組んでいるため、投稿に紐づくユーザーの情報を取得し表示することも可能です。
以下が、tweetsテーブルからusersテーブルを参照している部分です。

<span th:text="${tweet.user.username}"></span>
content.html
<div class="contents row" th:each="tweet: ${tweetList}"
		layout:fragment="content">
		<div class="content_post"
			style="background-image: url(th:text=${tweet.imageUrl});">
			<div class="more">
				<span> <img src="'arrow_top.png'">
				</span>
				<ul class="more_list">
					<li><a href="#">詳細</a></li>
					<li><a href="#">編集</a></li>
					<li><a href="#">削除</a></li>
				</ul>
			</div>
			<p th:text="${tweet.text}"></p>
			<a href="#"> <span class="name"> 投稿者<span
					th:text="${tweet.user.username}"></span>
			</span>
			</a>
		</div>
	</div>

以上で実装は終了です。
私自身、外部のテーブルから認証後のユーザーの情報を取得し、投稿用のテーブルにuser_idとして保存するという処理にかなり時間を労しました。

こういう投稿メインのアプリケーションではあるあるの実装だと思うので、SpringBootやJavaで実装している人の役に立てば幸いです。

#おわりに
とにかく、Java・Springのアノテーションは便利ということ。そして、Javaは難しいということを改めて痛感しました。

Ruby on Railsならコントローラーとモデルの記述だけで、実装できるようなところもJavaだと自分でやらないといけないのが難易度の高さかなと思います。

プログラミング言語は言語なので英語やドイツ語のように若干の違いがあるのだなと思います。だからこそ、面白くもあるのですが、、悩む時間も愛しいものです(笑)

#参考文献

Spring Security と Spring Bootで最小機能のデモアプリケーションを作成する

Discussion