feat(WIP): 添加单点登录(SSO)功能

- 新增 SSO 服务逻辑,包括授权地址生成、回调处理和用户信息获取。
- 新增 SSO Controller,提供登录、回调以及成功/失败处理的 API。
- 定义 SSO 模型类,用于 Access Token 和用户信息的封装。
- 当前逻辑以使用 Logto OSS 测试无误。
- 本功能未全部完成,待后续开发。
This commit is contained in:
OnEvent
2025-05-09 18:43:53 +08:00
parent e075c7104f
commit 89414dcfd9
4 changed files with 194 additions and 0 deletions
@@ -0,0 +1,67 @@
package im.zhaojun.zfile.module.sso.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.URLUtil;
import im.zhaojun.zfile.module.sso.service.SsoService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
@Slf4j
@Tag(name = "单点登录")
@RestController
@RequestMapping("/sso")
@RequiredArgsConstructor
class SsoController
{
private final SsoService ssoService;
@GetMapping("/login/{provider}")
public RedirectView login(@PathVariable String provider, HttpSession session)
{
var state = IdUtil.fastSimpleUUID();
session.setAttribute("state", state);
var url = ssoService.getAuthRedirectUrl(state);
var redirect = new RedirectView();
redirect.setUrl(url);
redirect.setStatusCode(HttpStatus.SEE_OTHER);
return redirect;
}
@GetMapping("/callback")
public RedirectView callback(@RequestParam("code") String code, @RequestParam("state") String state, HttpSession session)
{
if (!state.equals(session.getAttribute("state").toString()))
{
var err = URLUtil.encode("state 参数不一致");
return new RedirectView("/sso/error?err=" + err);
}
var url = ssoService.callbackHandler(code);
var redirect = new RedirectView();
redirect.setUrl(url);
redirect.setStatusCode(HttpStatus.SEE_OTHER);
return redirect;
}
@GetMapping("/success")
public String success()
{
return "单点登录成功, 当前用户 ID: [" + StpUtil.getLoginIdAsString() + "]!";
}
@GetMapping("/error")
public String error(@RequestParam("err") String err)
{
return "单点登录失败: " + err;
}
}
@@ -0,0 +1,16 @@
package im.zhaojun.zfile.module.sso.model.response;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class TokenResponse
{
private String accessToken;
private String idToken;
private String scope;
private String tokenType;
private Long expiresIn;
}
@@ -0,0 +1,13 @@
package im.zhaojun.zfile.module.sso.model.response;
import lombok.Data;
@Data
public class UserInfoResponse
{
private String sub;
private String name;
private String picture;
private String email;
}
@@ -0,0 +1,98 @@
package im.zhaojun.zfile.module.sso.service;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import im.zhaojun.zfile.module.sso.model.response.TokenResponse;
import im.zhaojun.zfile.module.sso.model.response.UserInfoResponse;
import org.springframework.stereotype.Service;
import java.util.HashMap;
/**
* 单点登录服务
*
* @author OnEvent
*/
@Service
public class SsoService
{
// TODO 这里需要修改为从数据库或配置文件中读取
// TODO 同时考虑是否需要利用 Well-Known 来获取相关信息
private static final String CLIENT_ID = "<Client ID>";
private static final String CLIENT_SECRET = "<Client Secret>";
private static final String AUTH_URL = "<Auth URL>";
private static final String TOKEN_URL = "<Token URL>";
private static final String USER_INFO_URL = "<User Info URL>";
private static final String REDIRECT_URI = "http://localhost:8080/sso/callback";
private static final String SCOPE = "openid profile email";
/**
* 获取 OIDC/OAuth2 的授权地址<br/>
* 并由后端控制页面重定向到 OIDC/OAuth2 服务器的授权页面
*
* @param state 状态值
* @return 授权地址
*/
public String getAuthRedirectUrl(String state)
{
var authParamsMap = new HashMap<String, String>()
{{
put("response_type", "code");
put("client_id", CLIENT_ID);
put("redirect_uri", REDIRECT_URI);
put("state", state);
put("scope", SCOPE);
}};
var authParamsStr = HttpUtil.toParams(authParamsMap);
return AUTH_URL + "?" + authParamsStr;
}
/**
* 处理 OIDC/OAuth2 的回调,并同时获取用户信息,利用用户信息中的邮箱完成登录<br/>
* 若一切顺利则返回到成功页面<br/>
* 当在获取 AK 和用户信息时发生错误时,返回错误页面
*
* @param code 授权码
* @return 重定向的页面路径
*/
public String callbackHandler(String code)
{
// 获取 Access Token
var tokenParamsMap = new HashMap<String, String>()
{{
put("code", code);
put("client_id", CLIENT_ID);
put("client_secret", CLIENT_SECRET);
put("redirect_uri", REDIRECT_URI);
put("grant_type", "authorization_code");
}};
var authParamsStr = HttpUtil.toParams(tokenParamsMap);
var result = HttpUtil.post(TOKEN_URL, authParamsStr);
var token = JSONUtil.toBean(result, TokenResponse.class);
if (ObjectUtil.isNull(token) || StrUtil.isEmpty(token.getAccessToken()))
{
return "/sso/error?err=获取 Access Token 失败"; // TODO 这里要确认,是否需要从后端控制跳转到错误页面,全部由后端控制可能会更简单一些
}
// 获取用户信息
var userInfoStr = HttpUtil.createGet(USER_INFO_URL).bearerAuth(token.getAccessToken()).execute().body();
var userInfo = JSONUtil.toBean(userInfoStr, UserInfoResponse.class);
if (ObjectUtil.isNull(userInfo) || StrUtil.isEmpty(userInfo.getEmail()))
{
return "/sso/error?err=获取用户信息失败"; // TODO 同上一个错误处理
}
// 调用 Sa Token 的登录方法
// TODO 这里要处理一下,如果没有对应用户,则创建用户
StpUtil.login(userInfo.getEmail(), new SaLoginParameter().setToken(token.getAccessToken()));
return "/sso/success";
}
}