Java EE 7基于数据库的Apache Shiro配置

上一篇文章我介绍了在Java EE环境中配置Shiro的基本方法, 但是在真正的开发过程中我们基本上不
会使用基于配置文件的用户角色配置, 大多数情况下我们会将用户, 角色和权限存储在数据库中, 然后我们告诉Shiro去数据库中取数据, 这样的配置更灵活且功能更强大.

这样使Shiro能读数据库(或LDAP, 文件系统等)的组件叫做Realm, 可以把Realm看作是一个安全专用的DAO, 下面我详细介绍一下如何配置Realm:

(所用到的技术和上一篇文章中的类似, 此外, 我们用到了JPA, 项目源码参考我的Github)

添加依赖

在上一篇文章所用的依赖基础上, 我们还需要添加:

<dependency>
  <groupId>org.hibernate.javax.persistence</groupId>
  <artifactId>hibernate-jpa-2.1-api</artifactId>
  <version>1.0.0.Final</version>
</dependency>
<dependency>
  <groupId>org.jboss.spec.javax.ejb</groupId>
  <artifactId>jboss-ejb-api_3.2_spec</artifactId>
  <version>1.0.0.Final</version>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>3.7</version>
</dependency>

添加用户等实体

ER图

这里我将用户(User), 角色(Role), 权限(Permission)的关系定义为:

为简单起见, ER图省略了表的字段, 这里仅说明表之间的关系: 一个用户可以有多个角色, 一个角色有多个权限.

JPA实体

src/main/java/com/github/holyloop/entity/User.java:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(unique = true, nullable = false)
private Long id;

@Column(nullable = false, unique = true)
private String username;

@Column(nullable = false)
private String password;

@Column(nullable = false)
private String salt;

@OneToMany(mappedBy = "user")
private Set<UserRoleRel> userRoleRels = new HashSet<>(0);

这里的 password 为密文密码, salt 为哈希加密用到的盐, 我们让每个用户都有一个随机盐.

src/main/java/com/github/holyloop/entity/Role.java:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(unique = true, nullable = false)
private Long id;

@Column(name = "role_name", nullable = false, unique = true)
private String roleName;

@OneToMany(mappedBy = "role")
private Set<UserRoleRel> userRoleRels = new HashSet<>(0);

@OneToMany(mappedBy = "role")
private Set<RolePermissionRel> rolePermissionRels = new HashSet<>(0);

roleName 如名字所述, 为权限名.

src/main/java/com/github/holyloop/entity/Permission.java:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(unique = true, nullable = false)
private Long id;

@Column(name = "permission_str", nullable = false)
private String permissionStr;

permissionStr 为权限字符串, 我一般偏爱将权限字符串定义为 resource-type:operation:instance , 意为操作(operation)资源(resource-type)实例(instance)的权限, 例如: org:update:1 即为"更新id为1的组织"权限. 这种组织权限的方式较灵活, 能精确到资源实例, 在对权限要求教高的系统中可以使用这种方式, 一般对于权限不要求精确到资源实例的系统用传统的RBAC即可满足要求. 关于权限的详细定义可参考Shiro的这个文档.

剩下的两个中间关系表较简单, 这里不详细说明.

修改shiro.ini

src/main/webapp/WEB-INF/shiro.ini:

[main]
authc.loginUrl = /login

credentialsMatcher = org.apache.shiro.authc.credential.Sha256CredentialsMatcher
credentialsMatcher.storedCredentialsHexEncoded = false
credentialsMatcher.hashIterations = 1024

dbRealm = com.github.holyloop.secure.shiro.realm.DBRealm
dbRealm.credentialsMatcher = $credentialsMatcher

securityManager.realms = $dbRealm

[urls]
/index.html = anon
/login = authc

比较之前的配置, 我删掉了 [users][roles] 块, 因为这些数据我们将从数据库中加载. 此外, 我配置了Shiro的密码匹配器 credentialsMatcher , 它将用sha256来做密码匹配; 然后还有我们这篇文章的重点 dbRealm , 这个Realm将由我们自己来实现.

实现Realm

我们的 DBRealm 将继承AuthorizingRealm, 然后覆盖它的 doGetAuthorizationInfodoGetAuthenticationInfo 方法, 这两个方法分别用来获取权限信息和认证信息:

src/main/java/com/github/holyloop/secure/shiro/realm/DBRealm.java:

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {

  String username = StringUtils.trim((String) principals.getPrimaryPrincipal());

  Set<String> roles = userService.listRolesByUsername(username);
  Set<String> permissions = userService.listPermissionsByUsername(username);

  SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
  authorizationInfo.setRoles(roles);
  authorizationInfo.setStringPermissions(permissions);

  return authorizationInfo;
}

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

  String username = StringUtils.trim((String) token.getPrincipal());
  if (StringUtils.isEmpty(username)) {
    throw new AuthenticationException();
  }

  User user = userService.getOneByUsername(username);
  if (user == null) {
    throw new AuthenticationException();
  }
  SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
    username,
    user.getPassword(),
    new SimpleByteSource(Base64.decode(user.getSalt())),
    getName());

  return authenticationInfo;
}

doGetAuthorizationInfo 将加载用户的角色和权限数据, doGetAuthenticationInfo 根据用户输入的口令查找对应用户的认证数据(密文密码+盐).

此外, 这两个方法都用到了 UserService , 它的实现比较简单, 如 getOneByUsername :

public User getOneByUsername(String username) {
  if (StringUtils.isEmpty(username)) {
    throw new IllegalArgumentException("username must not be null");
  }

  try {
    return userRepository.getOneByUsername(username);
  } catch (NoResultException e) {
    return null;
  }
}

UserService 则依赖 UserRepository , 它是基于JPA的数据库访问接口, 具体的实现细节可以参考我的Github上的源码.

添加用户

到上面为止, 用户认证及鉴权的部分已经基本完成了. 但只有鉴权功能对一个系统来说是不完整的, 我们还需要能添加新的用户. 我们给 UserService 添加一个添加新用户的接口:

public void addUser(UserDTO user) throws UsernameExistedException {
  if (user == null) {
    throw new IllegalArgumentException("user must not be null");
  }

  try {
    User duplicateUser = userRepository.getOneByUsername(user.getUsername());
    if (duplicateUser != null) {
      throw new UsernameExistedException();
    }
  } catch (NoResultException e) {}

  TwoTuple<String, String> hashAndSalt = CredentialEncrypter.saltedHash(user.getPassword());

  User entity = new User();
  entity.setUsername(user.getUsername());
  entity.setPassword(hashAndSalt.t1);
  entity.setSalt(hashAndSalt.t2);

  userRepository.save(entity);
}

UsernameExistedException 如该异常名所描述, 当新用户输入的用户名与已有的用户重复时抛出该异常. UserDTO 结构如下:

src/main/java/com/github/holyloop/dto/UserDTO.java :

private String username;
private String password;

这里我将它定义得很简单, 实际项目开发时它肯定会有更多的属性, 比如邮箱, 生日, 手机号等等.

此外很重要的一个功能是密码加密, 这里我实现了 CredentialEncrypter 这个加密器:

src/main/java/com/github/holyloop/secure/shiro/util/CredentialEncrypter.java :

private static RandomNumberGenerator rng = new SecureRandomNumberGenerator();

/**
* hash + salt
* 
* @param plainTextPassword
*            明文密码
* @return
*/
public static TwoTuple<String, String> saltedHash(String plainTextPassword) {
  if (StringUtils.isEmpty(plainTextPassword)) {
    throw new IllegalArgumentException("plainTextPassword must not be null");
  }

  Object salt = rng.nextBytes();
  String hash = new Sha256Hash(plainTextPassword, salt, 1024).toBase64();
  return new TwoTuple<>(hash, salt.toString());
}

为了更高的安全性, 我们为每个用户都生成一个随机盐 RandomNumberGenerator , saltedHash 将明文密码和随机盐Sha256加密然后base64编码. 方法将返回密文密码和随机盐.

到这里, 整个系统的加密和解密功能就算基本完成了, 接下来我们进行一个简单的应用.

基本应用

添加REST接口

这里我们实现一个添加新用户的接口:

src/main/java/com/github/holyloop/rest/controller/UserController.java:

@RequiresPermissions("user:insert")
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response addUser(UserDTO user) {
  if (user == null || StringUtils.isEmpty(user.getUsername()) || StringUtils.isEmpty(user.getPassword())) {
    return Response.status(Status.BAD_REQUEST).entity("username or password must not be null").build();
  }

  user.setUsername(StringUtils.trim(user.getUsername()));
  user.setPassword(StringUtils.trim(user.getPassword()));

  try {
    userService.addUser(user);
  } catch (UsernameExistedException e) {
    return Response.status(Status.BAD_REQUEST).entity("username existed").build();
  }

  return Response.status(Status.OK).build();
}

我这里以jax-rs的风格实现了这个接口, 实现逻辑很简单: 校验用户输入是否合法, 合法则调用 UserService.addUser() 添加该用户, 添加成功时返回200; 需要注意的是我在该接口上添加一个 @RequiresPermissions("user:insert") 注解, 这个注解说明接口的调用者需要有 user-insert 的权限.

部署测试

接下来我们将应用部署并用curl进行几个简单的测试.

  • 测试定义的 @RequiresPermissions("user:insert") 权限要求:
curl -i -d "username=guest&password=guest" -c cookies "localhost:8080/shiro-db/login"
curl -i -X POST -H "content-type:application/json" -b cookies \
> -d '{"username":"newuser", "password":"123"}' \
> localhost:8080/shiro-db/api/user

这里我以guest用户登录, 该用户没有 "user:insert" 的权限, 可以看到请求添加用户接口响应为403, :

然后换一个拥有 "user:insert" 权限的用户登录:

curl -i -d "username=root&password=secret" -c cookies "localhost:8080/shiro-db/login"
curl -i -X POST -H "content-type:application/json" -b cookies \
> -d '{"username":"newuser", "password":"123"}' \
> localhost:8080/shiro-db/api/user

可以看到响应为200, 然后我们去数据库中看一下有没有新添加的 "newuser" 用户:

可以看到用户添加成功, 并且密码为密文存储

  • 测试新用户登录
curl -i -d "username=newuser&password=123" -c cookies "localhost:8080/shiro-db/login"

登录成功.

  • 重复添加该新用户

我们在添加用户的逻辑里面定义了如果用户名重复, 则新用户应添加失败:

如我们所期望的, 服务器响应为400并提示我们"username existed".

以上就是Shiro基于数据库的用户,角色,权限配置的方法, 核心的地方在于Realm的配置, Realm负责获取用户的认证和鉴权信息; 为了提高认证信息的存储安全性, 我们还进行了密码的加密处理(对于一个完备的系统来说这必不可少); 对于规模稍大的系统来说, 纯基于数据库的认证/鉴权机制是不够的, 为了提高效率还需要添加一些缓存机制, 这篇文章暂时说到这里, 关于缓存, 我们后面再聊.

posted @ 2018-04-29 20:28  holyloop  阅读(231)  评论(0编辑  收藏  举报