前書き
・Springは最近始めたばっかりで、変な記述があるかも(一応動作検証はしてる)
・ググった結果の集合体なので、知らんうちにいろんなところから引用してると思う(主にQiitaとStack Overflow)
・基本、自分用のメモ目的
・コードを記載しているのは、ただの露出行為
・ここはフレームワークのクラスでできるよーとか突っ込み歓迎
目的
Spring Securityの認証機能を包括的にカスタマイズする記事がなかったので書いてみた。
MySQLテーブルをORMで取得、認証と認証失敗時のエラーメッセージをカスタマイズするまで。
バージョン
Spring Boot 1.5.2
用意するもの
- EntityManager
 - users及びauthoritiesテーブル(今回はMySQL)
 - UserDetailsをimplementしたEntityクラス
 - GrantedAuthorityをimplementしたEntityクラス
 - UserDetailsServiceをimplementしたServiceクラス
 - AuthenticationFailureHandlerをimplementしたクラス
 - WebSecurityConfigurerAdapterを継承したクラス
 - LoginControllerクラス
 
実装
DataSource, EntityManager等を定義したクラス
@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
    @Bean
    @Primary
    @ConfigurationProperties(prefix="xxx.datasource")
    public DataSource dataSource() {
        DataSource ds =  DataSourceBuilder.create().build();
        return ds;
    }
    @Bean
    @Primary
    public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setJpaVendorAdapter(vendorAdapter);
        factory.setPackagesToScan("xxx.entity");
        factory.setPersistenceUnitName("xxx");
        factory.setDataSource(dataSource());
        factory.setPersistenceProvider(new HibernatePersistenceProvider());
        return factory;
    }
    @Bean
    @Primary
    public PlatformTransactionManager transactionManager() {
        JpaTransactionManager txManager = new JpaTransactionManager();
        txManager.setEntityManagerFactory(entityManagerFactory().getObject());
        txManager.setPersistenceUnitName("xxx");
        return txManager;
    }
}
Hibernate周りの設定は適当。
users及びauthoritiesテーブル(今回はMySQL)
CREATE TABLE IF NOT EXISTS `users` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(32) NOT NULL,
  `password` VARCHAR(32) NOT NULL,
  `enabled` TINYINT(1) NOT NULL DEFAULT 1,
  `locked` TINYINT(1) NOT NULL DEFAULT 0,
  `expire_date` DATETIME NULL,
  `authorities_id` BIGINT(20) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `user_name_UNIQUE` (`username` ASC),
  INDEX `fk_users_authorities1_idx` (`authorities_id` ASC),
  CONSTRAINT `fk_users_authorities1`
    FOREIGN KEY (`authorities_id`)
    REFERENCES `authorities` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;
CREATE TABLE IF NOT EXISTS `authorities` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `authority` VARCHAR(255) NOT NULL,
  `expire_date` DATETIME NULL,
  PRIMARY KEY (`id`))
ENGINE = InnoDB;
外部キー制約がないとNetBeansがManyToOneのJoinColumn作ってくれないみたい。
enabled, locked及びそれぞれのexpire_dateがあるのは、I/Fのメソッドをオーバーライドするため。
他のIDEは知らん。
UserDetailsをimplementしたEntityクラス
NetBeansを使ってるので、新規->データベースからのエンティティ・クラス...でEntity自体は自動生成してくれる。
んで、Overrideすべきメソッドを追加する。
@Entity
@Table(name = "users")
@XmlRootElement
@NamedQueries({
        ...
})
public class Users implements Serializable, UserDetails {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "id")
    private Long id;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 32)
    @Column(name = "username")
    private String username;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 32)
    @Column(name = "password")
    private String password;
    @Basic(optional = false)
    @NotNull
    @Column(name = "enabled")
    private boolean enabled;
    @Basic(optional = false)
    @NotNull
    @Column(name = "locked")
    private boolean locked;
    @Column(name = "expire_date")
    @Temporal(TemporalType.TIMESTAMP)
    private Date expireDate;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "usersId")
    private List<Maps> mapsList;
    @JoinColumn(name = "authorities_id", referencedColumnName = "id")
    @ManyToOne(optional = false)
    private Authorities authoritiesId;
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    @Override
    public String getUsername() {
        return username;
    }
    public void setUsername(String username) {
        this.username = username;
    }
    @Override
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
    @Override
    public boolean isEnabled() {
        return enabled;
    }
    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
    public boolean getLocked() {
        return locked;
    }
    public void setLocked(boolean locked) {
        this.locked = locked;
    }
    @Override
    public boolean isAccountNonLocked() {
        return !locked;
    }
    public Date getExpireDate() {
        return expireDate;
    }
    public void setExpireDate(Date expireDate) {
        this.expireDate = expireDate;
    }
    
    @Override
    public boolean isAccountNonExpired() {
        return this.expireDate == null || new Date().before(this.expireDate);
    }
    public Authorities getAuthoritiesId() {
        return authoritiesId;
    }
    public void setAuthoritiesId(Authorities authoritiesId) {
        this.authoritiesId = authoritiesId;
    }
    
    @Override
    public boolean isCredentialsNonExpired() {
        return this.authoritiesId.getExpireDate() == null || new Date().before(this.authoritiesId.getExpireDate());
    }
    @Override
    public Collection<Authorities> getAuthorities() {
        Collection<Authorities> result = new ArrayList<>();
        result.add(this.authoritiesId);
        return result;
    }
}
インターフェースのメソッドを実装することでアカウントの有効性をチェックして、各種Exceptionを吐いてくれる。
GrantedAuthorityをimplementしたEntityクラス
@Entity
@Table(name = "authorities")
@XmlRootElement
@NamedQueries({
    ...
})
public class Authorities implements Serializable, GrantedAuthority {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "id")
    private Long id;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 255)
    @Column(name = "authority")
    private String authority;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "authoritiesId")
    private Collection<Users> usersCollection;
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getAuthority() {
        return authority;
    }
    public void setAuthority(String authority) {
        this.authority = authority;
    }
    public Date getExpireDate() {
        return expireDate;
    }
    public void setExpireDate(Date expireDate) {
        this.expireDate = expireDate;
    }
    @XmlTransient
    public Collection<Users> getUsersCollection() {
        return usersCollection;
    }
    public void setUsersCollection(Collection<Users> usersCollection) {
        this.usersCollection = usersCollection;
    }
}
ユーザに割り当てられる権限を保持する。
UserDetailsServiceをimplementしたServiceクラス
@Service
public class UserService implements UserDetailsService {
    private static final Logger LOGGER = LoggerFactory.getLogger(UserService.class);
    
    @Autowired
    private MessageSource messageSource;
    @PersistenceContext(unitName = "xxx")
    EntityManager entityManager;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Users users;
        try {
            users = entityManager.createNamedQuery("Users.findByUsername", Users.class).setParameter("username", username).getSingleResult();
        }
        catch(NonUniqueResultException | NoResultException ex) {
            LOGGER.error(ex.getMessage(), ex.getCause(), ex);
            users = new Users();
        }
        return users;
    }
}
このクラスでORMを介してユーザの情報を取得する。ちなみにnullを返すとInternalAuthenticationServiceExceptionを吐いて正しく認証されないので、ユーザが見つからないときはUsersをnewして返す。
AuthenticationFailureHandlerをimplementしたクラス
public class UserAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(UserAuthenticationFailureHandler.class);
    @Override
    public void onAuthenticationFailure(
            HttpServletRequest httpServletRequest,
            HttpServletResponse httpServletResponse,
            AuthenticationException authenticationException)
                    throws IOException, ServletException {
        String errorId = "";
        if(authenticationException instanceof DisabledException){
            errorId = "2";
        }
        else if(authenticationException instanceof LockedException){
            errorId = "3";
        }
        else if(authenticationException instanceof AccountExpiredException){
            errorId = "4";
        }
        else if(authenticationException instanceof CredentialsExpiredException){
            errorId = "5";
        }
        else {
            LOGGER.debug(authenticationException.getMessage(), authenticationException.getCause(), authenticationException);
            errorId = "1";
        }
        httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/login?error=" + errorId);
    }
}
認証に失敗したときに呼び出されるハンドラ。失敗の種類はauthenticationExceptionのクラスで判定。エラーメッセージの種類を渡してログイン画面にリダイレクト。
WebSecurityConfigurerAdapterを継承したクラス
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private static final Logger LOGGER = LoggerFactory.getLogger(WebSecurityConfig.class);
    @Autowired
    private UserService userService;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/css/**", "/fonts/**", "/js/**", "/img/**", "/webjars/**", "/login**").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginProcessingUrl("/auth")
                .defaultSuccessUrl("/")
                .loginPage("/login")
                .failureHandler(new UserAuthenticationFailureHandler())
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }
    @Autowired
    public void registerGlobalAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(new PlaintextPasswordEncoder());
    }
}
上で定義したクラスをここで認証機能に組み込む。"/login**"をpermitAll()しとかないとパラメータが渡せないので注意。
LoginControllerクラス
@Controller
public class LoginController {
    private static final Logger LOGGER = LoggerFactory.getLogger(LoginController.class);
    
    @Autowired
    private MessageSource messageSource;
    
    @RequestMapping(value = "/login", method = RequestMethod.GET)
    ModelAndView getView(@RequestParam("error") Optional<Integer> error) {
        ModelAndView mav = new ModelAndView("login");
        String message = "";
        switch(error.orElse(0)) {
            case 1:
                message = messageSource.getMessage("user.error.notfound", null, LocaleContextHolder.getLocale());
                break;
            case 2:
                message = messageSource.getMessage("user.error.disabled", null, LocaleContextHolder.getLocale());
                break;
            case 3:
                message = messageSource.getMessage("user.error.locked", null, LocaleContextHolder.getLocale());
                break;
            case 4:
                message = messageSource.getMessage("user.error.expired.account", null, LocaleContextHolder.getLocale());
                break;
            case 5:
                message = messageSource.getMessage("user.error.expired.credentials", null, LocaleContextHolder.getLocale());
                break;
        }
        mav.addObject("message", message);
        return mav;
    }
}
UserAuthenticationFailureHandlerからリダイレクトしたときに渡されたパラメータ(error)からメッセージを設定してテンプレートに渡す。Thymeleafを使ってる場合、<div th:text="${message}"></div>とでも書いておくと表示されるよ。
UserDetailsServiceをいじれば、認証に使うユーザ情報は如何様にでもなる。
いじょ。