🎉 完成基本的演示和样例

This commit is contained in:
loveuer
2024-10-23 17:46:15 +08:00
commit aefc004e33
56 changed files with 2648 additions and 0 deletions

View File

@ -0,0 +1,86 @@
package handler
import (
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"net/http"
"net/url"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/model"
)
//go:embed serve_approve.html
var pageApprove string
func Approve(c *nf.Ctx) error {
// 获取表单数据
type Req struct {
ClientId string `form:"client_id"`
RedirectURI string `form:"redirect_uri"`
Scope string `form:"scope"`
State string `form:"state"`
}
var (
ok bool
op *model.User
err error
req = new(Req)
uri *url.URL
client = new(model.Client)
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err)
}
if uri, err = url.Parse(req.RedirectURI); err != nil {
log.Warn("[S] parse redirect uri = %s, err = %s", req.RedirectURI, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid redirect uri")
}
if err = db.Default.Session().Where("client_id", req.ClientId).Take(client).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid client_id")
}
log.Error("[S] get client by id fail, client_id = %s, err = %s", req.ClientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
db.Default.Session().Clauses(clause.OnConflict{DoNothing: true}).
Create(&model.AuthorizationRecord{
UserId: op.Id,
ClientId: client.Id,
})
authorizationCode := uuid.New().String()[:8]
if err = cache.Client.SetEx(c.Context(), cache.Prefix+"auth_code:"+authorizationCode, op.Id, 10*time.Minute); err != nil {
return resp.Resp500(c, err)
}
qs := uri.Query()
qs.Add("code", authorizationCode)
qs.Add("client_id", req.ClientId)
qs.Add("scope", req.Scope)
qs.Add("state", req.State)
uri.ForceQuery = true
value := uri.String() + qs.Encode()
return c.RenderHTML("approve", pageApprove, map[string]interface{}{
"redirect_uri": value,
})
}

View File

@ -0,0 +1,121 @@
package handler
import (
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"net/http"
"net/url"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/model"
)
var (
//go:embed serve_authorize.html
pageAuthorize string
)
func Authorize(c *nf.Ctx) error {
type Req struct {
ClientId string `query:"client_id"`
ResponseType string `query:"response_type"`
RedirectURI string `query:"redirect_uri"`
Scope string `query:"scope"`
State string `query:"state"`
}
var (
ok bool
op *model.User
req = new(Req)
err error
client = &model.Client{}
authRecord = &model.AuthorizationRecord{}
uri *url.URL
)
if err = c.QueryParser(req); err != nil {
log.Error("[S] query parser err = %s", err.Error())
return c.Status(http.StatusBadRequest).SendString("Invalid request")
}
if req.ResponseType != "code" {
log.Warn("[S] response type = %s", req.ResponseType)
return c.Status(http.StatusBadRequest).SendString("Invalid request")
}
// 如果未登录,则跳转到登录界面
if op, ok = c.Locals("user").(*model.User); !ok {
log.Info("[S] op not logined, redirect to login page")
return c.Redirect("/oauth/v2/login?"+c.Request.URL.Query().Encode(), http.StatusFound)
}
log.Info("[S] Authorize: username = %s, client_id = %s", op.Username, req.ClientId)
if err = db.Default.Session().Where("client_id", req.ClientId).Take(client).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid client_id")
}
log.Error("[Authorize]: db take clients err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if err = db.Default.Session().Model(&model.AuthorizationRecord{}).
Where("user_id", op.Id).
Where("client_id", client.Id).
Take(authRecord).
Error; err != nil {
// 用户第一次对该 client 进行授权
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.RenderHTML("authorize", pageAuthorize, map[string]any{
"user": map[string]any{
"username": op.Username,
"avatar": "https://picsum.photos/200",
},
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
})
}
log.Error("[Authorize]: db take authorization_records err = %s", err.Error())
return resp.Resp500(c, err)
}
// 当用户已经授权过时
// 生成授权码并缓存授权码
log.Debug("[Authorize]: username = %s already approved %s", op.Username, client.Name)
authorizationCode := uuid.New().String()[:8]
if err = cache.Client.SetEx(c.Context(), cache.Prefix+"auth_code:"+authorizationCode, op.Id, 10*time.Minute); err != nil {
return resp.Resp500(c, err)
}
if uri, err = url.Parse(req.RedirectURI); err != nil {
log.Warn("[S] parse redirect uri = %s, err = %s", req.RedirectURI, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid redirect uri")
}
qs := uri.Query()
qs.Add("code", authorizationCode)
qs.Add("client_id", req.ClientId)
qs.Add("scope", req.Scope)
qs.Add("state", req.State)
uri.ForceQuery = true
value := uri.String() + qs.Encode()
return c.RenderHTML("approve", pageApprove, map[string]interface{}{
"redirect_uri": value,
})
}

View File

@ -0,0 +1,137 @@
package handler
import (
_ "embed"
"errors"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"net/http"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
var (
//go:embed serve_login.html
pageLogin string
)
func LoginPage(c *nf.Ctx) error {
type Req struct {
ClientId string `query:"client_id" json:"client_id"`
Scope string `query:"scope" json:"scope"`
RedirectURI string `query:"redirect_uri" json:"redirect_uri"`
State string `query:"state" json:"state"`
ResponseType string `query:"response_type" json:"response_type"`
}
var (
err error
req = new(Req)
client = new(model.Client)
)
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.ClientId == "" || req.RedirectURI == "" {
return resp.Resp400(c, req)
}
if err = db.Default.Session().Model(&model.Client{}).
Where("client_id = ?", req.ClientId).
Take(client).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusForbidden).SendString("Client Not Registry")
}
log.Error("[S] model take client id = %s, err = %s", req.ClientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
return c.RenderHTML("login", pageLogin, map[string]interface{}{
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
"response_type": req.ResponseType,
"client_name": client.Name,
"client_icon": client.Icon,
})
}
//go:embed serve_login_success.html
var pageLoginSuccess string
func LoginAction(c *nf.Ctx) error {
type Req struct {
Username string `form:"username"`
Password string `form:"password"`
ClientId string `form:"client_id"`
RedirectURI string `form:"redirect_uri"`
Scope string `form:"scope"`
State string `form:"state"`
ResponseType string `form:"response_type"`
}
var (
err error
req = new(Req)
op = new(model.User)
token string
)
if err = c.BodyParser(req); err != nil {
log.Warn("[S] LoginAction: body parser err = %s", err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
if req.Username == "" || req.Password == "" {
return c.Status(http.StatusBadRequest).SendString("Bad Request: username, password is required")
}
if err = db.Default.Session().Model(&model.User{}).
Where("username = ?", req.Username).
Take(op).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn("[S] LoginAction: username = %s not found", req.Username)
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
log.Error("[S] LoginAction: model take username = %s, err = %s", req.Username, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
// todo: 验证用户登录是否成功,等等
if !tool.ComparePassword(req.Password, op.Password) {
log.Warn("[S] LoginAction: model take username = %s, password is invalid", req.Username)
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
if token, err = op.JwtEncode(); err != nil {
log.Error("[S] LoginAction: jwtEncode err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
key := cache.Prefix + "token:" + token
if err = cache.Client.SetEx(c.Context(), key, op, 24*time.Hour); err != nil {
log.Error("[S] LoginAction: cache SetEx err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
c.Writer.Header().Add("Set-Cookie", "access_token="+token)
return c.RenderHTML("login_success", pageLoginSuccess, map[string]interface{}{
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
"response_type": req.ResponseType,
})
}

View File

@ -0,0 +1,111 @@
package handler
import (
_ "embed"
"errors"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
func ClientRegistry(c *nf.Ctx) error {
type Req struct {
ClientId string `json:"client_id"`
Icon string `json:"icon"` // url
Name string `json:"name"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
Secret := tool.RandomString(32)
platform := &model.Client{
ClientId: req.ClientId,
Icon: req.Icon,
Name: req.Name,
ClientSecret: Secret,
}
if err = db.Default.Session().Create(platform).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return resp.Resp400(c, err, "当前平台已经存在")
}
return resp.Resp500(c, err)
}
return resp.Resp200(c, platform)
}
var (
//go:embed serve_registry.html
registryLogin string
)
func UserRegistryPage(c *nf.Ctx) error {
return c.HTML(registryLogin)
}
func UserRegistryAction(c *nf.Ctx) error {
type Req struct {
Username string `form:"username"`
Nickname string `form:"nickname"`
Password string `form:"password"`
ConfirmPassword string `form:"confirm_password"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if err = tool.CheckPassword(req.Password); err != nil {
return resp.Resp400(c, req, err.Error())
}
op := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Password: tool.NewPassword(req.Password),
}
if err = db.Default.Session().Create(op).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return resp.Resp400(c, err, "用户名已存在")
}
return resp.Resp500(c, err)
}
return c.HTML(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>注册成功</title>
</head>
<body>
<h1>注册成功</h1>
<h3>快去试试吧</h3>
</body>
</html>
`)
}

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>授权成功</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<span aria-busy="true">授权成功, 正在跳转回原网页...</span>
<div style="display: none">
<input type="hidden" id="redirect_uri" value="{{ .redirect_uri }}"/>
</div>
<script type="text/javascript">
setTimeout(() => {
console.log('[D] after 1s console')
let redirect_uri = document.getElementById('redirect_uri').value
window.location.href = redirect_uri
}, 1000)
</script>
</body>
</html>

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<div>
<h3>授权登录到 {{ .client_name }} 平台</h3>
<div class="userinfo">
<article style="display: flex; align-items: center;">
<div style="height:50px; width:50px;">
<img src="{{ .user.avatar }}"/>
</div>
<div style="margin-left:20px; ">
{{ .user.username }}
</div>
</article>
</div>
<form action="/oauth/v2/approve" method="POST">
<fieldset>
<input type="hidden" name="client_id" value="{{ .client_id }}"/>
<input type="hidden" name="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" name="scope" value="{{ .scope }}"/>
<input type="hidden" name="state" value="{{ .state }}"/>
</fieldset>
<div style="display: flex;">
<button type="button" class="contrast" style="flex:1; margin-right: 10px">取消</button>
<button type="submit" style="flex: 1;">授权</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
div.row {
display: flex;
align-items: center;
max-width: 33%;
height: 50px;
overflow: hidden;
flex: 1;
}
div.row:nth-child(2) {
justify-content: center;
}
div.row:last-child {
margin-left: auto;
}
</style>
</head>
<body>
<div>
<h3>欢迎来到 UAuth</h3>
<article style="display: flex; align-items: center;">
<div class="row">
<div style="height:50px; width:50px;">
<img src="https://picsum.photos/seed/drealism/200"/>
</div>
<div style="margin-left:10px; ">UAuth</div>
</div>
<div style="transform: rotate(90deg);" class="row">
<svg t="1730168415342" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1981" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40"><path d="M428.3 66.4c-12-5-25.7-2.2-34.9 6.9l-320 319.6c-12.5 12.5-12.5 32.7 0 45.3 12.5 12.5 32.7 12.5 45.3 0l265.4-265V928c0 17.7 14.3 32 32 32s32-14.3 32-32V96c-0.1-12.9-7.9-24.6-19.8-29.6zM950.6 585.8c-12.5-12.5-32.8-12.5-45.3 0L640 850.8V96c0-17.7-14.3-32-32-32s-32 14.3-32 32v832c0 12.9 7.8 24.6 19.7 29.6 4 1.6 8.1 2.4 12.2 2.4 8.3 0 16.5-3.2 22.6-9.4l320-319.6c12.6-12.4 12.6-32.7 0.1-45.2z" p-id="1982"></path></svg>
</div>
<div class="row">
<div style="height:50px; width:50px;">
<img src="{{ .client_icon }}"/>
</div>
<div style="margin-left:10px; ">
{{ .client_name }}
</div>
</div>
</article>
<form action="/oauth/v2/login" method="POST">
<fieldset>
<label>
Username
<input
name="username"
placeholder="username"
autocomplete="given-name"
/>
</label>
<label>
Password
<input
type="password"
name="password"
placeholder="password"
autocomplete="password"
/>
</label>
<input type="hidden" name="client_id" value="{{ .client_id }}"/>
<input type="hidden" name="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" name="scope" value="{{ .scope }}"/>
<input type="hidden" name="state" value="{{ .state }}" />
<input type="hidden" name="response_type" value="{{ .response_type }}" />
</fieldset>
<input
type="submit"
value="登录"
/>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录成功</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<span aria-busy="true">登录成功, 正在跳转...</span>
<div style="display: none">
<input type="hidden" id="client_id" value="{{ .client_id }}"/>
<input type="hidden" id="scope" value="{{ .scope }}"/>
<input type="hidden" id="state" value="{{ .state }}"/>
<input type="hidden" id="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" id="response_type" value="{{ .response_type }}"/>
</div>
<script type="text/javascript">
setTimeout(() => {
console.log('[D] after 1s console')
let client_id = document.querySelector('#client_id').value;
let scope = document.querySelector('#scope').value;
let state = document.querySelector('#state').value;
let redirect_uri = document.querySelector('#redirect_uri').value;
let response_type = document.querySelector('#response_type').value;
window.location.href = `/oauth/v2/authorize?client_id=${client_id}&scope=${scope}&redirect_uri=${redirect_uri}&state=${state}&response_type=${response_type}`;
}, 1000)
</script>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<div>
<h3>欢迎注册 UAuth</h3>
<form action="/api/oauth/v2/registry/user" method="POST" id="form">
<fieldset>
<label>
用户名
<input id="username" name="username" autocomplete="given-name"/>
</label>
<label>
昵称
<input id="nickname" name="nickname" autocomplete="given-name"/>
</label>
<label>
密码
<input id="password" type="password" name="password" autocomplete="password"/>
</label>
<label>
重复密码
<input id="confirm_password" type="password" name="confirm_password" autocomplete="password"/>
</label>
<button type="button" style="flex: 1;width: 100%;" onclick="registry()">注册</button>
</fieldset>
</form>
</div>
<script type="text/javascript">
function registry() {
let user = {
username: document.querySelector("#username").value,
nickname: document.querySelector("#nickname").value,
password: document.querySelector("#password").value,
confirm_password: document.querySelector("#confirm_password").value,
}
console.log('[D] user = ', user)
if (!user.username || !user.password || !user.nickname) {
window.alert("参数均不能为空")
return
}
if (user.password !== user.confirm_password) {
window.alert("两次密码不一致")
return
}
document.querySelector("#form").submit()
}
</script>
</body>
</html>

View File

@ -0,0 +1,109 @@
package handler
import (
"encoding/base64"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"gorm.io/gorm"
"net/http"
"strings"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
func HandleToken(c *nf.Ctx) error {
type Req struct {
Code string `form:"code"`
GrantType string `form:"grant_type"`
RedirectURI string `form:"redirect_uri"`
}
var (
err error
req = new(Req)
opId uint64
op = new(model.User)
token string
basic string
bs []byte
strs []string
client = new(model.Client)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid form")
}
// client_secret
if basic = c.Get("Authorization"); basic == "" {
return c.Status(http.StatusUnauthorized).SendString("Authorization header missing")
}
switch {
case strings.HasPrefix(basic, "Basic "):
basic = strings.TrimPrefix(basic, "Basic ")
default:
return c.Status(http.StatusBadRequest).SendString("Bad Request: authorization scheme not supported")
}
if bs, err = base64.StdEncoding.DecodeString(basic); err != nil {
log.Warn("[Token] base64 decode failed, raw = %s, err = %s", basic, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid basic authorization")
}
if strs = strings.SplitN(string(bs), ":", 2); len(strs) != 2 {
log.Warn("[Token] basic split err, decode = %s", string(bs))
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid basic authorization")
}
clientId, clientSecret := strs[0], strs[1]
if err = db.Default.Session().Model(&model.Client{}).
Where("client_id", clientId).
Take(client).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: client invalid")
}
log.Error("[Token] db take client by id = %s, err = %s", clientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if client.ClientSecret != clientSecret {
log.Warn("[Token] client_secret invalid, want = %s, got = %s", client.ClientSecret, clientSecret)
return c.Status(http.StatusUnauthorized).SendString("Unauthorized: client secret invalid")
}
if err = cache.Client.GetScan(tool.Timeout(2), cache.Prefix+"auth_code:"+req.Code).Scan(&opId); err != nil {
if errors.Is(err, cache.ErrorKeyNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid code")
}
log.Error("[S] handleToken: get code from cache err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
op.Id = opId
if err = db.Default.Session().Take(op).Error; err != nil {
log.Error("[S] handleToken: get op by id err, id = %d, err = %s", opId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if token, err = op.JwtEncode(); err != nil {
log.Error("[S] handleToken: encode token err, id = %d, err = %s", opId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
refreshToken := uuid.New().String()
return c.JSON(map[string]any{
"access_token": token,
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": 24 * 3600,
})
}