如何在 grails 和现有 oauth2 提供程序中使用 Spring Security 实现基于表单的登录

Posted

技术标签:

【中文标题】如何在 grails 和现有 oauth2 提供程序中使用 Spring Security 实现基于表单的登录【英文标题】:How to implement form-based login with spring security in grails and existing oauth2 provider 【发布时间】:2017-12-17 15:06:53 【问题描述】:

大家好,我有一个新手 grails / spring 安全问题。我们使用spring-security-oauth2-provider 在我们的 grails 3 项目中设置了 oauth2,并且似乎可以保护我们的 REST API。

但后来我们开始使用 GSP 在同一个项目中添加 Web 前端,结果走到了十字路口。通常 oauth2 通过对端点进行身份验证并接收令牌来工作,它可以在后续请求的 HTTP 标头中使用令牌来继续访问受保护的资源。但是我的 Web 前端有一个登录页面。因此,最初我们认为将 Web 前端视为客户端之一(我们的 ios 应用程序有 1 个客户端,android 应用程序有 1 个客户端,那么为什么我们的 Web 应用程序也有 1 个)。但是从我们的控制器代码(用于登录)向我们的 oauth2 提供程序端点发出 HTTP 请求似乎很奇怪,因为它在同一个项目中;以及我的 Web 前端需要发出的大多数后续请求,我们希望直接访问底层服务和域对象,因此添加额外的跃点似乎适得其反。

所以我们选择的是,当我使用我的 login.gsp 登录时,在控制器代码中,我绕过 oauth2 登录,并通过使用 authenticationManager.authenticate() 和我构造的 UsernamePasswordAuthenticationToken 进行直接的弹簧安全认证从传递给我的表单的用户名和密码字段,然后在响应中调用 SecurityContextHolder.getContext().setAuthentication()。这种一半解决了我的问题,因为this 帖子告诉我这样做只会在当前线程上设置 SecurityContextHolder,并且由于 SecurityContextHolder 在过滤器链的末尾被清除,后续请求将不会被验证。所以我所做的是如果此身份验证通过(即没有抛出异常),然后我将我的用户对象放入 HTTP 会话和所有后续请求中,尝试检索它作为“告诉”我已通过身份验证的一种方式。但这似乎既老套又肮脏。这会导致我的用户对象未附加到数据库会话(导致延迟初始化异常)。

我确实从 this 之类的帖子中找到了其他类似的建议,它们将整个 SecurityContext 置于 HTTP 会话中,但似乎没有说明如何在后续请求中使用该 SecurityContext。

我想我最终的问题是,我们是不是走错了路?有没有更好更干净的方式来完成我想做的事情?我想我们不可能是第一个尝试这样做的人。

【问题讨论】:

【参考方案1】:

鉴于我已经给出了答案,我想知道 SO 将如何处理这个答案。唉,谁真的在乎呢——反正我是第一个问这个问题的人。

通过我看过的所有文档,例如Grails Security - Reference Documentation 和 Spring Security Core Plugin - Reference Documentation 和 Grails Spring Security Core Plugin Custom Authentication 和 How to customize spring security plugin Login page in grails 我能够发现,即使我们使用的是 Spring Security OAuth2 插件,我们也获得了 Spring Security Core 插件提供的所有功能。这意味着开箱即用支持基于表单的登录,也支持自定义登录表单。

对于自定义登录表单,我们仍然可以使用 $request. contextPath/登录/认证。

所以我确实尝试过,当然首先禁用了我们的拦截器,但最终出现了导致浏览器退出的重定向循环。我认为我们需要做一些我们没有的配置(使用 chainMap 或过滤器),这就是它仍然不起作用的原因。

有一个帮助是在 grails-app/conf/logback.groovy 中添加以下两行

logger 'org.springframework.security', DEBUG, ['STDOUT'], false
logger 'grails.plugin.springsecurity', DEBUG, ['STDOUT'], false

这给了我很多日志信息来帮助调试。出于某种原因,我意识到它以某种方式在我的请求中插入了一个匿名令牌,因为某些内容没有经过身份验证。

经过一段时间的调试,终于发现答案就在application.groovy中。我遇到的问题是,因为默认情况下所有应用程序都有一个 Spring Security filterChain,它的最后一行 /** 匹配 JOINED_FILTERS。我们在安装 Spring Security OAuth2 Provider 时添加的行有 /** match JOINED_FILTERS 减去一堆其他过滤器。那行实际上是正确的,但是因为我们从未删除原始的 /** 行,并且优先顺序是这一行在这一行之前。所以我需要做的就是删除与 JOINED_FILTERS 匹配的 /** 的第一行,然后登录现在可以工作了。

最后,我们在 filterChain 中的行是:

[pattern: '/rest/**', filters: 'JOINED_FILTERS,-securityContextPersistenceFilter,-logoutFilter,-authenticationProcessingFilter,-rememberMeAuthenticationFilter,-oauth2BasicAuthenticationFilter,-exceptionTranslationFilter'],
// We want all the other resources to be Web-based
[pattern: '/web/**',  filters: 'JOINED_FILTERS,-statelessSecurityContextPersistenceFilter,-oauth2ProviderFilter,-clientCredentialsTokenEndpointFilter,-oauth2BasicAuthenticationFilter,-oauth2ExceptionTranslationFilter'],
// This is just a catch-all that defaults to the same logic as before, it also would match things like /oauth/** or /auth/**
[pattern: '/**',      filters: 'JOINED_FILTERS']

而且我们还有一些其他配置用于指向我们自己的 AuthController 的覆盖:

grails.plugin.springsecurity.auth.loginFormUrl = '/auth/login' // This is our own Login Form
grails.plugin.springsecurity.failureHandler.defaultFailureUrl = '/auth/login' // Exception message shown in controller
grails.plugin.springsecurity.adh.errorPage = '/auth/denied' // Copied from LoginController

【讨论】:

【参考方案2】:

有点难过,我正在回答我自己的问题。但我想看看我的解决方案是否是解决这个问题的正确方法。

所以我们最终做的是使用过滤器。我们知道 Interceptors 应该是 Grails 3 中的一种方式,但在到达那里之前,我们只想拥有一个带过滤器的工作版本。

在我们的 AuthController.signIn 方法中,我们通过 UsernamePasswordAuthenticationToken 身份验证过程,实际上将 SecurityContext 推入 HTTP 会话。顺便说一句,很有趣,我后来发现这实际上已经为我完成了(使用“SPRING_SECURITY_CONTEXT”键),而无需我显式地执行 session.setAttribute。我的理由是,由于 SecurityContext 仅使用 UsernamePasswordAuthenticationToken 对此请求进行身份验证,因此我需要将其保留在 HTTP 会话中,以便为后续请求重新加载它。因此,在我作为第一个顺序放置的过滤器中,我检查 HTTP 会话中的 SecurityContext 并将其“身份验证”复制到当前的 SecurityContext 中。请注意,我们也已经有一个过滤器,按顺序放在最后,如果在 HTTP 会话中没有找到任何内容,则将用户重定向到登录页面。

通过此更改,我们现在可以访问 Controller 中的 springSecurityService 以访问当前登录的用户(之前我们不能)。我们得到的其他好处包括能够在我们的 GSP 页面中使用 sec:ifAllGranted。但是有些事情仍然不可行 - 我们似乎仍然无法使用 @Secured("#oauth2.isUser()") 注释来保护我们的 Controller 方法,总是打 401。也许我们使用 UsernamePasswordAuthenticationToken 进行身份验证仍然不是我们需要“完整”的身份验证?

我进一步尝试将 OAuth2 访问令牌而不是 SecurityContext 推入 HTTP 会话,并在我的过滤器中添加值为“Bearer”+accessToken 的“授权”HTTP 标头。这不起作用,初步记录显示,在一个 HTTP 请求(页面视图)中,我多次进入我的过滤器,并且只有第一次成功地将 OAuth2 访问令牌放入请求的 HTTP 标头中。

所以说到底,我想知道的是,我上面的方法是否正确,可以使用过滤器让用户在会话中保持联系?或者我应该以不同的方式做到这一点?另外,如何使用 @Secured 注释保护我的控制器?

【讨论】:

我不应该再对我再次回答我自己的问题感到惊讶了。也许是因为答案太明显了,也许没有人遇到过这种情况;无论哪种方式,我都会在 SO 上“写博客”我的旅程,以防其他人发现这很有用。对我的问题的简短回答是,不——所有这些都是绝对不必要的。 Spring Security 带有许多我们可以配置的默认/内置功能。但由于 cmets 最多有 600 个字符,我将以另一个答案的形式发布我的附录,但在我用尽所有空间之前不会。

以上是关于如何在 grails 和现有 oauth2 提供程序中使用 Spring Security 实现基于表单的登录的主要内容,如果未能解决你的问题,请参考以下文章

Grails OAuth2 登录密码凭据授予返回 invalid_client

Grails 中的 Spring Oauth2 提供程序 - 依赖项

Grails spring security oauth2 提供者对具有正确承载令牌的资源的请求重定向到登录

如何绕过 Grails 异常处理以使用 Spring Oauth2?

Grails 和 Spring Security & Facebook & Oauth2 - Restful 服务器

如何将 Oauth2 服务器正确设置为 Spring 现有 API?