認証機能つきWebAPIをGrails3でサクッと用意する
Posted on January 07, 2018 at 23:42 (JST)
最近フロントエンド(Vue2でSPA)の勉強しています。
ちょっと実践的なことを試したくなるとWebAPIが欲しくなりますよね。
そんな時はGrails3
を使ってサクッと用意しちゃいましょう!
今回は下記2点の仕様を満たすWebAPIを作成しました。
- JWTによる認証
- ResponseはJSON、かつフィールドは snake_case
作成したサンプルはGithubにて公開しています。[ grails3-rest ]
2018/01/09 追記
この記事ではレスポンスのsnake_case化を行いましたが、
これだけではRequestをsnake_caseで送ってもエラーになってしまいますので注意して下さい。
動作環境
OS: macOS High Sierra ver. 10.13.2
Java: 8u152-zulu
Grails: 3.2.10
Groovy: 2.4.7
Gradle: 3.4.1
JWTによる認証
Spring Security REST Plugin を利用します。
1. ライブラリを準備する
build.gradle の dependencies
に下記を追加する
compile "org.grails.plugins:spring-security-core:3.0.3"
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"
2. domainクラスを準備する
grails-app/domain
下に User, Role, RoleUser を用意する
User.groovy
package grails3.rest
import grails.util.Holders
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {
private static final long serialVersionUID = 1
transient springSecurityService
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
User(String username, String password) {
this()
this.username = username
this.password = password
}
Set<Role> getAuthorities() {
UserRole.findAllByUser(this)*.role
}
def beforeInsert() {
encodePassword()
}
def beforeUpdate() {
if (isDirty('password')) {
encodePassword()
}
}
protected void encodePassword() {
if (!springSecurityService) {
springSecurityService = Holders.applicationContext.getBean('springSecurityService')
}
password = springSecurityService.encodePassword(password)
}
static transients = ['springSecurityService']
static constraints = {
username blank: false, unique: true
password blank: false
}
static mapping = {
password column: '`password`'
}
}
Holders.applicationContext.getBean('springSecurityService')
がポイント。
というか、最近のバージョンアップでDIまわりがかわったのか、参考にした記事そのままでは動かなくて変更を要した部分になります。
Role.groovy
package grails3.rest
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable {
private static final long serialVersionUID = 1
String authority
Role(String authority) {
this()
this.authority = authority
}
static constraints = {
authority blank: false, unique: true
}
static mapping = {
cache true
}
}
UserRole.groovy
package grails3.rest
import grails.gorm.DetachedCriteria
import groovy.transform.ToString
import org.apache.commons.lang.builder.HashCodeBuilder
@ToString(cache=true, includeNames=true, includePackage=false)
class UserRole implements Serializable {
private static final long serialVersionUID = 1
User user
Role role
UserRole(User u, Role r) {
this()
user = u
role = r
}
@Override
boolean equals(other) {
if (!(other instanceof UserRole)) {
return false
}
other.user?.id == user?.id && other.role?.id == role?.id
}
@Override
int hashCode() {
def builder = new HashCodeBuilder()
if (user) builder.append(user.id)
if (role) builder.append(role.id)
builder.toHashCode()
}
static UserRole get(long userId, long roleId) {
criteriaFor(userId, roleId).get()
}
static boolean exists(long userId, long roleId) {
criteriaFor(userId, roleId).count()
}
private static DetachedCriteria criteriaFor(long userId, long roleId) {
UserRole.where {
user == User.load(userId) &&
role == Role.load(roleId)
}
}
static UserRole create(User user, Role role, boolean flush = false) {
def instance = new UserRole(user: user, role: role)
instance.save(flush: flush, insert: true)
instance
}
static boolean remove(User u, Role r, boolean flush = false) {
if (u == null || r == null) return false
int rowCount = UserRole.where { user == u && role == r }.deleteAll()
if (flush) { UserRole.withSession { it.flush() } }
rowCount
}
static void removeAll(User u, boolean flush = false) {
if (u == null) return
UserRole.where { user == u }.deleteAll()
if (flush) { UserRole.withSession { it.flush() } }
}
static void removeAll(Role r, boolean flush = false) {
if (r == null) return
UserRole.where { role == r }.deleteAll()
if (flush) { UserRole.withSession { it.flush() } }
}
static constraints = {
role validator: { Role r, UserRole ur ->
if (ur.user == null || ur.user.id == null) return
boolean existing = false
UserRole.withNewSession {
existing = UserRole.exists(ur.user.id, r.id)
}
if (existing) {
return 'userRole.exists'
}
}
}
static mapping = {
id composite: ['user', 'role']
version false
}
}
3. 設定ファイルを準備する
grails-app/conf/spring
下に application.groovy を用意する
grails.plugin.springsecurity.userLookup.userDomainClassName = 'grails3.rest.User'
grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'grails3.rest.UserRole'
grails.plugin.springsecurity.authority.className = 'grails3.rest.Role'
grails.plugin.springsecurity.controllerAnnotations.staticRules = [
[
pattern: '/**',
access : ['ROLE_USER', 'ROLE_ADMIN']
]
]
grails.plugin.springsecurity.filterChain.chainMap = [
//Stateless chain
[
pattern: '/api/**',
filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
]
]
初期データ投入
今回はアプリケーション起動時におこないます。
grails-app/init/grails3/rest/BootStrap.groovy
に下記を記述
def init = { servletContext ->
def adminRole = new Role('ROLE_ADMIN').save(flush: true)
def adminUser = new User('demo', 'demo').save(flush: true)
UserRole.create adminUser, adminRole
UserRole.withSession {
it.flush(); it.clear()
}
def userRole = new Role('ROLE_USER').save(flush: true)
def testUser = new User('me', 'password').save(flush: true)
UserRole.create testUser, userRole
UserRole.withSession {
it.flush(); it.clear()
}
assert User.count() == 2
assert Role.count() == 2
assert UserRole.count() == 2
}
※ 開発時のみ上記処理をおこなう事もできます。
公式ドキュメントのPer Environment Bootstrapping
を参照
動作検証
$ ./grailsw run-app
でアプリケーションを起動する
$ curl -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{"username":"me", "password":"password"}' http://localhost:8080/api/login
{
"username": "me",
"roles": [
"ROLE_USER"
],
"token_type": "Bearer",
"access_token": "eyJhbGciO<中略>94ZSybAI",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJI<中略>NuExE"
}
ResponseはJSON、かつフィールドは snake_case
ObjectMarshaller
を追加することでフィールド名をデフォルトのcamelCaseからsnake_caseに変更できます。
1. インターフェースを準備する
grails-app/controllers/grails3/rest/SnakeCase.groovy
を作成する。
package grails3.rest
interface SnakeCase {
}
2. ドメインクラスを準備する
1で作成したインターフェースをimplementsしたDomainクラスを作成する。
@Resource
を付与しRESTエンドポイントを自動作成する。
package grails3.rest
import grails.rest.Resource
@Resource
class Customer implements SnakeCase {
String firstName
String lastName
String email
Date birthday
static constraints = {
firstName size: 1..255, blank: false
lastName size: 1..255, blank: false
email email: true, blank: false, unique: true
birthday nullable: true
}
}
3. mapping設定を行いRequestを到達させる
grails-app/controllers/grails3/rest/UrlMappings.groovy
のmappings
に下記を追加
"/api/customers"(resources:"customer")
4. Marshaller を登録する
BootStrap.groovy
のinit
に下記を追加
def grailsApplication = Holders.applicationContext.getBean('grailsApplication')
JSON.registerObjectMarshaller(SnakeCase, { SnakeCase o ->
def result = [:]
if (grailsApplication.isDomainClass(o.class)) {
result['id'] = o['id']
def domainClass= grailsApplication.getDomainClass(o.class.name)
domainClass.persistentProperties.each { GrailsDomainClassProperty p ->
def name = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, p.name)
result[name] = o[p.name]
}
} else {
o.properties.each {
def name = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.key as String)
result[name] = it.value
}
}
return result
})
result['id'] = o['id']
をしないとid
が出力対象にならないので注意。
5. 初期データ投入
BootStrap.groovy
のinit
にてきとうに追加。(省略)
動作確認
未認証の場合
$ curl -H "Accept: application/json" -H "Content-type: application/json" -X GET http://localhost:8080/api/customers
{"timestamp":1515334567257,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/customers"}
認証時のアクセストークンを使用
$ curl -H "Accept: application/json" -H "Content-type: application/json" -H "authorization:Bearer eyJhbGc<中略>94ZSybAI" -X GET http://localhost:8080/api/customers
[{"id":2,"birthday":"1991-03-20T15:00:00Z","email":"ann_miles@example.com","first_name":"Ann","last_name":"Miles"}]
おわりに
ひとこと
本当にサクッと出来てたら、この記事書いてないわ
たったこれだけの記述量でDBに登録まで出来る、認証機能付きWebAPIを作成する事ができました。
手元に動くコード用意しておくと、いろいろ便利になりそうです♪
参考
今回は下記の記事を参考にしました。