技术开发 频道

精通 Grails: 身份验证和授权

  【IT168技术文档】Grails 提供了组成安全 Web 应用程序所需的所有基本构建模块,包括从简单的登录基础设施到基于角色的授权等各种组件,在本期的 精通 Grails 中,Scott Davis 帮助您通过动手操作保护 Grails 应用程序。您还将了解一些插件,可以帮助您以不同的方式扩展应用程序的安全功能。

  在本文中,我将继续构建一个“微型博客” Blogito。我删除了此前文章(“用定制 URI 和 codec 优化 Grails 中的 URI”)中的 User,因为 name 字段是 URI 的重要组成部分。这一次我们将实现完整的 User 子系统。您将理解到如何根据 User 是否登录启用登录、限制用户行为,甚至根据 User 的角色添加一些授权。

  首先,User 需要一种登录方式,从而能够发布新的条目。

  身份验证

  对于支持多个用户的博客服务器来说,进行身份验证是个好主意。您肯定不希望 John Doe 以 Jane Smith 的身份发布博客条目,不管是有意还是无意。设置身份验证基础设施将回答这个问题:“您是谁?”,稍后,您还将添加一些授权机制。授权将回答关于 “允许您做什么” 的问题。

  清单 1 展示了您在 在上一篇文章 中创建的 grails-app/domain/User.groovy 文件:

  清单 1. User 类

class User {
  static constraints
= {
    login(unique:
true)
    password(password:
true)
    name()
  }
  
  static hasMany
= [entries:Entry]
  
  
String login
  
String password
  
String name
  
  
String toString(){
    name
  }
}

  login 和 password 字段已经就绪。您现在只需要提供一个控制器和一个表单。创建 grails-app/controllers/UserController.groovy 并添加如清单 2 所示的代码:

  清单 2. 将 login、authenticate 和 logout 闭包添加到 UserController

class UserController {
  def scaffold
= User
  
  def login
= {}
  
  def authenticate
= {
    def user
= User.findByLoginAndPassword(params.login, params.password)
    
if(user){
      session.user
= user
      flash.message
= "Hello ${user.name}!"
      redirect(controller:
"entry", action:"list")      
    }
else{
      flash.message
= "Sorry, ${params.login}. Please try again."
      redirect(action:
"login")
    }
  }
  
  def logout
= {
    flash.message
= "Goodbye ${session.user.name}"
    session.user
= null
    redirect(controller:
"entry", action:"list")      
  }  
}

  空的 login 闭包仅仅表示在您的浏览器中访问 http://localhost:9090/blogito/user/login 将呈现 grails-app/views/user/login.gsp 文件(您稍后即将创建该文件)。

  authenticate 闭包使用了一个方便的 GORM 方法(findByLoginAndPassword() )执行需要的操作:在数据库中查找 User,该 User 的 login 和 password 匹配表单字段中输入的值,并通过 params hashmap 使用户可用。如果 User 存在的话,将它添加到会话中。如果不存在的话,重定向回登录表单以允许 User 再一次提供正确的凭证。logout 闭包将执行 User 退出,将他或她从会话中删除,然后重定向回 EntryController 中的 list 操作。

  现在让我们开始创建 login.gsp。可以手动输入清单 3 中所示的代码,或者可以执行下面的操作:

  在命令行输入 grails generate-views User。

  将 create.gsp 复制到 login.gsp。

  简化生成的代码。

  清单 3. login.gsp

<html>
  
<head>
    
<meta name="layout" content="main" />
    
<title>Login</title>        
  
</head>
  
<body>
    
<div class="body">
      
<h1>Login</h1>
      
<g:if test="${flash.message}">
        
<div class="message">${flash.message}</div>
      
</g:if>
      
<g:form action="authenticate" method="post" >
        
<div class="dialog">
          
<table>
            
<tbody>            
              
<tr class="prop">
                
<td class="name">
                  
<label for="login">Login:</label>
                
</td>
                
<td>
                  
<input type="text" id="login" name="login"/>
                
</td>
              
</tr>
          
              
<tr class="prop">
                
<td class="name">
                  
<label for="password">Password:</label>
                
</td>
                
<td>
                  
<input type="password" id="password" name="password"/>
                
</td>
              
</tr>
            
</tbody>
          
</table>
        
</div>
        
<div class="buttons">
          
<span class="button">
            
<input class="save" type="submit" value="Login" />
          
</span>
        
</div>
      
</g:form>
    
</div>
  
</body>
</html>

  注意,表单的 action 是 authenticate,它匹配 UserController.groovy 中的闭包的名称。输入元素( login 和 password )中的名称对应于 authenticate 闭包中的 params.login 和 params.password。

  输入 grails run-app 并运行您的身份验证基础设施。尝试使用密码 foo 以 jsmith 的身份登录(记住在 “用定制 URI 和 codec 优化 Grails 中的 URI” 中,您在 grails-app/conf/BootStrap.groovy 中为 Blogito 提供了一些用户)。您的登录将失败,如图 1 所示:

  图 1. 失败的登录尝试,显示错误消息  

  再次以 jsmith 的身份和密码 wordpass 尝试登录。这一次应当成功。

  如果欢迎消息没有出现在 grails-app/views/entry/list.gsp 中 — 并且它不应该出现 — 那么只需将 块从 login.gsp 复制到 list.gsp 文件的顶部。再次以 jsmith 身份登录,检验现在是否显示了如图 2 所示的消息:

  图 2. 确认成功登录的 Flash 消息  

  现在可以确定身份验证能够正常工作,应当创建一个 TagLib 来简化登录和退出。

  创建一个身份验证 TagLib

  像 Google 和 Amazon 这样的 Web 站点在标题处提供了一个不太显眼的文本链接,允许您登录和退出。您只需要几行代码就可以在 Grails 中实现这一点。

  首先,在命令提示下输入 grails create-tag-lib Login。将清单 4 中的代码添加到新创建的 grails-app/taglib/LoginTagLib.groovy 中:

  清单 4. LoginTagLib.groovy

class LoginTagLib {
  def loginControl
= {
    
if(session.user){
      out
<< "Hello ${session.user.name} "
      out
<< """[${link(action:"logout", controller:"user"){"Logout"}}]"""
    }
else {
      out
<< """[${link(action:"login", controller:"user"){"Login"}}]"""      
    }
  }
}

  现在,将新的 标记添加到 grails-app/views/layouts/_header.gsp,如清单 5 所示:

 

  清单 5. 将 标记添加到标题

<div id="header">
  
<p><g:link class="header-main" controller="entry">Blogito</g:link></p>
  
<p class="header-sub">A tiny little blog</p>
  
  
<div id="loginHeader">
    
<g:loginControl />
  
</div>
</div>

  最后,将针对 loginHeader的一些 CSS 格式添加到 web-app/css/main.css,如清单 6 所示:

 

  清单 6. loginHeader的 CSS 格式

 

 

#loginHeader {
  float:
right;
  color: #fff;
}

  重启 Grails 并以 jsmith 身份登录后,屏幕应该如图 3 所示:

 

  图 3. 实际使用 Login TagLib  

 

  基本授权

  现在 Blogito 已经实现了身份验证,接下来是限制您所能执行的操作。例如,任何人都应当能够读取 Entry,但是只有登录用户能够创建、更新和删除 Entry。要达到这个目的,Grails 提供了一个 beforeInterceptor,顾名思义,它为您提供一个钩子,可以在调用目标闭包之前对行为进行授权。

  将清单 7 中的代码添加到 EntryController:

  清单 7. 向 EntryController 添加授权

class EntryController {

  def beforeInterceptor
= [action:this.&auth, except:["index", "list", "show"]]

  def auth() {
    
if(!session.user) {
      redirect(controller:
"user", action:"login")
      return
false
    }
  }

  def list
= {
    
//snip...
  }
}

  auth 和 list 之间微妙但重要的一点区别是 list 是一个闭包,而 auth 是一个私有方法(闭包在定义中使用等号;方法使用圆括号)。闭包以 URI 的形式被公开给最终用户;方法则无法从浏览器中进行访问。

  auth 方法将执行检查,查看某个 User 是否在会话中。如果不在的话,它将重定向到登录屏幕并返回 false,阻塞初始的闭包调用。

  在 beforeInterceptor 调用每个闭包之前,auth 方法将得到调用。该操作使用 Groovy 标记来指向 this 类的 auth 方法,该方法使用了 ampersand(&)字符。except 列表包含了应当从 auth 调用中移除的闭包。如果希望拦截一些闭包调用,可以使用 only 替换 except(有关 beforeInterceptor 的更多信息,参见 参考资料)。

  重新启动 Grails 并测试 beforeInterceptor。尝试在未登录的情况下访问 http://localhost:9090/blogito/entry/create。您应当被重定向到登录屏幕。以 jsmith 身份登录并重新尝试。这一次您应当能够成功创建新的 Entry。

  细粒度授权

  beforeInterceptor 提供的粗粒度授权仅仅是个开始,但是也可以向单独的闭包添加授权钩子。例如,任何已登录的 User(不仅仅是初始创建者)都可以编辑任何 Entry。可以关闭安全漏洞:将 4 行良好布置的代码添加到 EntryController.groovy 中的 edit 闭包中,如清单 8 所示:

  清单 8. 向 edit 闭包添加授权

def edit = {
    def entryInstance
= Entry.get( params.id )
    
    
//limit editing to the original author
    
if( !(session.user.login == entryInstance.author.login) ){
      flash.message
= "Sorry, you can only edit your own entries."
      redirect(action:list)
    }

    
if(!entryInstance) {
        flash.message
= "Entry not found with id ${params.id}"
        redirect(action:list)
    }
    
else {
        return [ entryInstance : entryInstance ]
    }
}

  您可以(也应该)使用相同的四行代码锁定 delete 和 update 闭包。如果来回复制和粘帖相似代码的工作非常繁琐(并且应当会如此),那么可以创建一个单一的私有方法并在所有三个闭包中调用它。如果发现在许多控制器内使用的是相同的 beforeInterceptor 和私有方法,那么可以将常见的行为解析为单个主控制器,并使用其他控制器扩展它,就像在任何 Java 类中所做的那样。

  可以向授权基础设施添加另外一项内容以使它变得更加健壮:角色

  添加角色

  为 User 分配角色是一种方便的分组方法。随后可以向组分配权限,而不是向个人分配权限。例如,现在任何人都可以创建一个新的 User。仅仅检查某个用户是否登录还远远不够。我希望限制管理员管理 User 帐户的权限。

  清单 9 向 User 添加了一个角色字段以及一条限制,限制 author 或 admin 的值:

  清单 9. 向 User 添加一个角色字段

class User {
  static constraints
= {
    login(unique:
true)
    password(password:
true)
    name()
    role(inList:[
"author", "admin"])
  }
  
  static hasMany
= [entries:Entry]
  
  
String login
  
String password
  
String name
  
String role = "author"
  
  
String toString(){
    name
  }
}

  注意,role 默认值为 author。inList 限制给出了一个复选框,只显示了两个有效选项。图 4 展示了它的实际使用:

  图 4. 将新用户角色限制为 author 或 admin  

  在 grails-app/conf/BootStrap.groovy 中创建一个 admin User,如清单 10 所示。不要忘记将 author role 添加到两个现有的 User 中。

  清单 10. 添加一个 admin User

import grails.util.GrailsUtil

class BootStrap {
  def init
= { servletContext ->
    switch(GrailsUtil.environment){
      
case "development":
        def admin
= new User(login:"admin",
                             password:
"password",
                             name:
"Administrator",
                             role:
"admin")
        admin.save()
      
        def jdoe
= new User(login:"jdoe",
                            password:
"password",
                            name:
"John Doe",
                            role:
"author")
        
//snip...
        
        def jsmith
= new User(login:"jsmith",
                            password:
"wordpass",
                            name:
"Jane Smith",
                            role:
"author")
        
//snip...
        
      break

      
case "production":
      break
    }

  }
  def destroy
= {
  }
}

  最后,添加清单 11 中的代码,将所有 User 帐户活动限制为只有拥有 admin 角色的人员才能执行:

  清单 11. 将 User 帐户管理限制为只有拥有 admin 角色的人员才能执行

class UserController {
  
  def beforeInterceptor
= [action:this.&auth,
                           except:[
"login", "authenticate", "logout"]]

  def auth() {
    
if( !(session?.user?.role == "admin") ){
      flash.message
= "You must be an administrator to perform that task."
      redirect(action:
"login")
      return
false
    }
  }
  
  
//snip...
}  

  要测试基于角色的授权,以 jsmith 身份登录并随后尝试访问 http://localhost:9090/blogito/user/create。应当被重定向到登录屏幕,如图 5 所示:

  图 5. 阻塞非管理员访问  

  现在以 admin 用户的身份登录。应当能够访问所有的闭包。

  使用插件实现更高级功能

  这个 “微型” 博客应用程序的 “微型” 身份验证和授权系统现在已经初具雏形。您可以轻松地对它进行扩展。也许您希望 User 能够管理他们各自的帐户,而不是其他人的。也许 admin 应当具备编辑所有 Entries 的能力,而不仅仅是编辑他们自己的。在这些情况下,只需要策略性地放置几行代码就可以添加新的功能。

  人们常常将简洁性误解为缺乏功能。Blogito 仍然不足 200 行代码 — 并且这还包含了单元和集成测试。在命令行输入 grails stats 以确认这点。结果如清单 12 所示。但是 Blogito 不复杂并不表示它的功能不完备。

  清单 12. “微型” 应用程序的大小

$ grails stats

    
+----------------------+-------+-------+
    | Name                 | Files |  LOC  |
    
+----------------------+-------+-------+
    | Controllers          |    
2 |    95 |
    | Domain Classes       |    
2 |    32 |
    | Tag Libraries        |    
2 |    21 |
    | Unit Tests           |    
5 |    20 |
    | Integration Tests    |    
1 |    10 |
    
+----------------------+-------+-------+
    | Totals               |    
12 |   178 |
    
+----------------------+-------+-------+

  从本系列的第一篇文章开始,我的目标就是向您展示核心 Grails 与生俱来的强大功能,以及 Groovy 语言的简洁的表达能力。例如,一旦理解了 Grails 的编解码器,就可能打乱数据库中存储的密码,而不是以简洁的形式显示出来(有关 HashCodec 的更多信息,参见 参考资料)。创建 grails-app/utils/HashCodec.groovy 并添加清单 13 中的代码:

  清单 13. 创建一个简单的 HashCodec

import java.security.MessageDigest
import sun.misc.BASE64Encoder
import sun.misc.CharacterEncoder

class HashCodec {
  static encode
= { str ->
    MessageDigest md
= MessageDigest.getInstance('SHA')
    md.update(str.getBytes('UTF-8'))
    return (new BASE64Encoder()).encode(md.digest())
  }
}

  有了 HashCodec 之后,只需要在 UserController 的 login、save 和 update 闭包中将对 User.password 的引用修改为 User.password.encodeAsHash()。令人惊讶的是,只需要 10 行代码,您让应用程序变得更高级。

  但是,有时并不是增加代码就能获得回报。对于 Grails 中,典型的 “构建还是购买” 问题变成了 “构建还是下载插件”。http://grails.org/plugin/list#security+tags 中的一些插件试图解决身份验证和授权挑战,使用了与 grails install-plugin 不同的方法。

  比如,Authentication 插件提供了一些非常不错的特性,例如允许 User 注册一个帐户,而不是要求 admin 为他们创建帐户。随后可以配置此插件,向 User 发送一条确认消息,表示 “使用这个电子邮件地址创建了一个新的用户帐户。单击此链接将验证您的新帐户”。

  OpenID 插件则采取不同的方法。您的最终用户不需要创建另一个用户名和密码组合(他们肯定会遗忘),身份验证被委托给他们选择的 OpenID 提供商。Lightweight Directory Access Protocol (LDAP) 插件采用了类似地方法,允许您的 Grails 应用程序利用现有的 LDAP 基础设施。

  Authentication 和 OpenID 插件只提供身份验证功能。其他插件还提供了授权解决方案。JSecurity 插件提供了一个完整的安全框架,为 User、Role 和 Permission 提供了模板(boilerplate)域类。Spring Security 插件利用了 Spring Security (formerly Acegi Security) 库,允许您重用现有的 Spring Security 知识和源代码。

  可以看到,Grails 中可以应用多种身份验证和授权策略,因为应用程序之间的需求是不一样的。通过在功能中设置这些策略,应用程序不可避免地将增加相应的复杂性。在生产应用程序中,我曾使用了这里列出的一些插件,但前提是,必须确保使用插件带来的优点超过了我最早给出的简单的 hand-rolled 策略的好处。

  结束语

  您现在拥有了一个安全的 Blogito。User 拥有了一种登录和退出方法,以及一个可用于执行这些操作的方便的链接集合,这全部归功于所创建的 LoginTagLib。在某些情况下,只需要登录到应用程序就足够保证安全性了,正如检验身份验证的 EntryController 中的 beforeInterceptor 所展示的那样。对于其他情况,角色让授权更加高级。向 User 添加简单的角色允许将用户管理访问限制为只能由管理员执行。

  现在 Blogito 已经具备了安全性,在下一期精通 Grails 文章中,我们将关注目前最主要的任务 — 为通过身份验证的用户提供一种方法来上传文件,以及为最终用户提供一种方法来订阅 Atom 提要。具备了这些功能后,Blogito 将真正成为一个博客应用程序。到那时,请尽情享受精通 Grails 的乐趣吧!

0
相关文章