アレについて記す

認証機能つきWebAPIをGrails3でサクッと用意する

Posted on January 07, 2018 at 23:42 (JST)

最近フロントエンド(Vue2でSPA)の勉強しています。
ちょっと実践的なことを試したくなるとWebAPIが欲しくなりますよね。
そんな時はGrails3を使ってサクッと用意しちゃいましょう!

今回は下記2点の仕様を満たすWebAPIを作成しました。

  1. JWTによる認証
  2. 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.groovymappingsに下記を追加

"/api/customers"(resources:"customer")

4. Marshaller を登録する

BootStrap.groovyinitに下記を追加

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.groovyinitにてきとうに追加。(省略)

動作確認

未認証の場合

$ 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を作成する事ができました。
手元に動くコード用意しておくと、いろいろ便利になりそうです♪

参考

今回は下記の記事を参考にしました。