基于Maxkey Oauth2接入Grafana,实现单点登录

1、接入版本

Maxkey v4.0.3GA Grafana 9.0.7

2、Maxkey接入Grafana的认证流程

3、具体实现步骤

3.1、修改Grafana配置,开启Oauth认证

修改custom.ini文件,如果没有custom.ini文件,可从conf下复制default.ini文件,然后改名为custom.ini。

代码语言:txt
复制
#################################### Server ####################
[server]
# 将Grafana的访问地址设置为Maxkey的访问地址,便于将cookie存入同一个域名下
domain = sso.maxkey.top

添加/grafana路径,便于在nginx中进行拦截跳转

root_url = %(protocol)s://%(domain)s/grafana

#################################### Security #######################
[security]

该参数关系到oauth_state的生成规则,无需修改

secret_key = SW2YcwTIb9zpOOhoPsMm

#################################### Generic OAuth #################
[auth.generic_oauth]
name = OAuth
icon = signin
enabled = true
allow_sign_up = true
auto_login = true
#Maxkey平台颁发的client_id
client_id = xxxxxxx
#Maxkey平台颁发的client_secret
client_secret = xxxxxxx
scopes = user:read
empty_scopes = false
email_attribute_name =
email_attribute_path =
login_attribute_path = user
name_attribute_path =
role_attribute_path =
role_attribute_strict = false
groups_attribute_path =
id_token_attribute_name =
team_ids_attribute_path =
auth_url = http://sso.maxkey.top/sign/authz/oauth/v20/authorize
token_url = http://sso.maxkey.top/sign/authz/oauth/v20/token
api_url = http://sso.maxkey.top/sign/api/oauth/v20/me
teams_url =
allowed_domains =
team_ids =
allowed_organizations =
tls_skip_verify_insecure = false
tls_client_cert =
tls_client_key =
tls_client_ca =
use_pkce = false

#################################### Basic Auth #####################
[auth.basic]
#关闭默认的登录方式
enabled = false

3.2、Maxkey的相关配置

3.2.1、新增maxkey-web 认证平台模块下application-http.properties配置
代码语言:txt
复制
#填写Grafana中的secret_key的值
maxkey.sso.grafana.secretKey = SW2YcwTIb9zpOOhoPsMm
#Maxkey颁发给Grafana的秘钥
maxkey.sso.grafana.ClientSecret = xxxxx

3.2.2、通过Maxkey的管理平台配置Grafana认证信息

3.2.3、在Nginx中新增配置
代码语言:txt
复制
server {
listen 3000;
server_name localhost;

location /grafana{
    proxy_pass http://sso.maxkey.top:3000/grafana;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $host:3000;
    server_name_in_redirect on;
}

}

3.2.4、对Maxkey的maxkey-protocol-oauth模块做适应性的修改

1)对org.maxkey.authz.oauth2.provider.endpoint.org.maxkey.authz.oauth2.provider.endpoint类进行修改

代码语言:txt
复制
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.SecureRandom;

@Tag(name = "2-1-OAuth v2.0 API文档模块")
@Controller
@RefreshScope
public class AuthorizationEndpoint extends AbstractEndpoint {

private static final String OAUTH_STATE_COOKIE_NAME = "oauth_state";
// An highlighted block
@Value("${maxkey.sso.grafana.secretKey}")
private String grafanaSecretKey;

@Value("${maxkey.sso.grafana.ClientSecret}")
private String ssoClientSecret;

/隐藏无需修改的方法/

private String getAuthorizationCodeResponse(AuthorizationRequest authorizationRequest, Authentication authUser,String returnUrl) {
try {
String successfulRedirect = getSuccessfulRedirect(
authorizationRequest,
generateCode(authorizationRequest, authUser)
);
logger.info("getAuthorizationCodeResponse returnUrl:"+returnUrl);

        //往grafana跳转登录,写入oauth_state参数
        if(successfulRedirect.contains("grafana")){
            String state = this.generateStateString();
            String hashStatecode = this.hashStatecode(state,grafanaSecretKey,ssoClientSecret);
            successfulRedirect = successfulRedirect + "&state="+state;
            HttpServletRequest request = WebContext.getRequest();
            String serverName = request.getServerName();

WebContext.setCookie(WebContext.getResponse(),serverName,OAUTH_STATE_COOKIE_NAME,hashStatecode,10);
}
_logger.debug("successfulRedirect " + successfulRedirect);
return successfulRedirect;
}
catch (OAuth2Exception e) {
return getUnsuccessfulRedirect(authorizationRequest, e, false);
}
}

/**
* @description: 对状态码做Hash运算
* @date: 2024/4/19 11:02
* @param state 状态码
* @return
*/
private String hashStatecode(String state,String grafanaSecretKey,String ssoClientSecret) {
String combinedString = state + grafanaSecretKey + ssoClientSecret;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(combinedString.getBytes());
return DatatypeConverter.printHexBinary(hashBytes).toLowerCase();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}

    return null;
}

/**
 * @description: 生成grafana验证的状态码
 * @date: 2024/4/19 11:03
 * @return
 */
private  String generateStateString() {
    SecureRandom secureRandom = new SecureRandom();
    byte[] randomBytes = new byte[32];
    secureRandom.nextBytes(randomBytes);
    return  Base64.getUrlEncoder().encodeToString(randomBytes);
}
/*隐藏无需修改的方法*/

}

2)对org.maxkey.authz.oauth2.provider.code.AuthorizationCodeTokenGranter进行修改

代码语言:txt
复制
public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {

@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

   /*隐藏无需修改的方法*/
    
    Set<String> redirectUris = client.getRegisteredRedirectUri();
    boolean redirectMismatch=false;
    //match the stored RedirectUri with request redirectUri parameter
    for(String storedRedirectUri : redirectUris){
        //解决从https跳转至http域名下,获取access_token失败问题
        if(redirectUri.startsWith(storedRedirectUri) || storedRedirectUri.contains("grafana")){
            redirectMismatch=true;
        }
    }
    
    if ((redirectUri != null || redirectUriApprovalParameter != null)
            && !redirectMismatch) {
        logger.info("storedAuth redirectUri "+pendingOAuth2Request.getRedirectUri());
        logger.info("redirectUri parameter "+ redirectUri);
        logger.info("stored RedirectUri "+ redirectUris);
        throw new RedirectMismatchException("Redirect URI mismatch.");
    }
    
    if (clientId != null && !clientId.equals(pendingClientId)) {
        // just a sanity check.
        throw new InvalidClientException("Client ID mismatch");
    }
    
    /*隐藏无需修改的方法*/
    
    return new OAuth2Authentication(finalStoredOAuth2Request, userAuth);

}

}

上述代码块只是对getOAuth2Authentication方法进行了修改,主要修改以下代码,排除了对Grafana的重定向接口的校验,防止获取access_token失败的问题(如果是通过ip访问,可以不做此修改)。

代码语言:txt
复制
if(redirectUri.startsWith(storedRedirectUri) || storedRedirectUri.contains("grafana")){
redirectMismatch=true;
}

3.3、线上部署的几种方案

说明:以下几种方案,都是采用将Grafana的访问地址代理到同一个Ip或域名下,防止Cookie跨域丢失。

3.3.1、通过Ip+端口的访问方式

IP

组件

192.168.1.15

Maxkey服务

192.168.1.15

Nginx

192.168.1.16

Grafana服务

1) 在Maxkey的管理平台将grafana的登录地址和授权地址,全部设置为maxkey部署服务器的IP(192.168.1.15),然后在grafana部署服务器上,将custom.ini的domain设置为192.168.1.15,rool_url后边加上/grafana路径。如下图

3.3.2、通过域名跳转IP的访问方式

IP /域名

组件

sso.maxkey.top

Nginx(域名代理服务器)

192.168.1.15

Maxkey服务

192.168.1.16

Grafana服务

1)在Maxkey管理平台中将grafana的登录地址和授权地址,全部设置为https://sso.maxkey.top/,将customt.ini的domain设置为sso.maxkey.top,root_url 参数后边加上/grafana路径。

2)新增Nginx配置

代码语言:txt
复制
server {
listen 443 ssl;
server_name sso.maxkey.top;

ssl_certificate      /ssl/maxkey.pem;
ssl_certificate_key  /ssl/maxkey.key;

ssl_session_cache    shared:SSL:1m;
ssl_session_timeout  5m;

ssl_ciphers  HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers  on;

location /{
        proxy_pass http://192.168.1.15/;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

}

server {
listen 3000 ssl;
server_name sso.maxkey.top;

ssl_certificate      /ssl/maxkey.pem;
ssl_certificate_key  /ssl/maxkey.key;

ssl_session_cache    shared:SSL:1m;
ssl_session_timeout  5m;

ssl_ciphers  HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers  on;

location /grafana {
        proxy_pass http://192.168.1.16:3000/grafana;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host:3000;
        server_name_in_redirect on;
        proxy_set_header X-Forwarded-Proto $scheme;
}

}

4、Maxkey接入Grafana过程中遇到的问题及解决方案

4.1、Grafana登录报错login.OAuthLogin(missing saved state)

原因分析:

Grafana通过Oauth方式认证登录时,会校验cookie中是否有oauth_state参数,没有就会报missing saved state错误。

解决方案:

在Maxkey认证完成,即将重定向跳转到Grafana登录接口时,将oauth_state状态码写入到cookie中。

具体操作,请查看3.2.4章节。

代码语言:txt
复制
/Grafana是go语言写的,以下展示的Grafana部分源代码/
//生成一个随机的状态码
func GenStateString() (string, error) {
rnd := make([]byte, 32)
if _, err := rand.Read(rnd); err != nil {
oauthLogger.Error("failed to generate state string", "err", err)
return "", err
}
return base64.URLEncoding.EncodeToString(rnd), nil
}
/**

  • @description: 对状态码做Hash运算
  • @param code 传入的状态码
  • @param seed Maxkey颁发给Grafana的秘钥,即client_secret
  • @param SecretKey 是custom.ini文件中配置的secret_key的值
    */
    func (hs *HTTPServer) hashStatecode(code, seed string) string {
    hashBytes := sha256.Sum256([]byte(code + hs.Cfg.SecretKey + seed))
    return hex.EncodeToString(hashBytes[:])
    }

展示上述代码,是为了在Maxkey项目中,需要用java实现这两个方法,解决状态码缺失问题。

4.2、Grafana登录报错login.OAuthLogin(state mismatch)

原因分析:

重定向URL地址传递的state参数,做哈希运算后,与cookie中存入的oauth_state不相等造成的。

解决方案:

1)先清除一下缓存,排除一下缓存造成的bug(博主就在这踩过坑,花了几个小时排除代码与参数,血泪史。。。)

2)比对Maxkey中生成oauth_state时用到的client_secret和secret_key是否一致

3)检查go方法中GenStateString()和hashStatecode()的这两个方法,是否在转译java代码时,转译正确。

4.3、Grafana登录报错login.OAuthLogin(NewTransportWithCode)

原因分析:

一般造成这个错误的原因是Maxkey管理平台中配置的授权地址和Grafana配置文件中root_url 不一致,导致Grafana在通过code换取access_token时失败造成的。

解决方案:

可以参考章节3.2.4 (2)中的修改方法,有点简单粗暴,博主由于时间原因,把Grafana所有的这类校验都硬编码排除了,可能存在安全性问题,建议大家可以根据实际情况优化

4.4、其他

建议allowed_domains 设置为空,否则登录时会要检查邮箱域名;

Maxkey登录的用户,建议填写一个合规的邮箱(满足邮箱地址规则即可),否则Grafana会报用户邮箱为空之类的错误。

以上两点,博主在实操过程中遇到,不一定必现,大家感兴趣可以验证下

5、参考

https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/generic-oauth/

https://maxkey.top/zh/conf/tutorial.html

https://help.aliyun.com/zh/grafana/use-cases/use-oauth-to-log-on-to-grafana

https://blog.csdn.net/qq_43801592/article/details/123062161

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/memory23/article/details/138276050