Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Cai f4be6e135a templates: Translate to en_US 2023-02-24 16:17:04 +01:00
Peter Cai 950f521002 Add "nickname" claim for better OIDC compat 2023-02-24 16:10:16 +01:00
Peter Cai 20ad51f214 feat: Add "sub" claim for the openid scope
Required by OIDC clients like Gitea
2023-02-24 16:05:47 +01:00
Peter Cai 7de5f11bcc Update .gitignore 2023-02-24 00:46:31 +01:00
Nya Candy fec344dc45
chore: merge /api/ 2023-02-17 23:24:00 +08:00
Nya Candy efe707fd16
feat: any
any is fine
2023-01-29 17:22:21 +08:00
Nya Candy 22d44480c9
feat: limit scopes max height to prevent page overflow 2023-01-29 16:08:10 +08:00
Nya Candy 369db32569
fix: page not full on firefox 2023-01-29 14:57:31 +08:00
Nya Candy a04c5b062e
feat: configurable timings 2023-01-29 14:55:56 +08:00
Nya Candy a99045a034
feat(refractor): use misskey userinfo key as scope
...and UI tweaks 🎨
2023-01-29 14:23:49 +08:00
22 changed files with 288 additions and 281 deletions

View File

@ -7,3 +7,8 @@ misskey:
secret: "" secret: ""
hydra: hydra:
admin_url: "http://localhost:4445" admin_url: "http://localhost:4445"
time:
request_valid: 3600
login_remember: 600
consent_remember: 0
userinfo_cache: 3600

3
.gitignore vendored
View File

@ -4,3 +4,6 @@
db/ db/
redis/ redis/
config.yml
misso

View File

@ -1,8 +1,8 @@
package consts package consts
const ( const (
REDIS_KEY_LOGIN_SESSION = "misso:login:%s" // Username, token as value REDIS_KEY_LOGIN_SESSION = "misso:login:%s" // Username, token as value
REDIS_KEY_CONSENT_CSRF = "misso:consent:%s" // Random string, consent challenge as value REDIS_KEY_CONSENT_CSRF = "misso:consent:%s" // Random string, consent challenge as value
REDIS_KEY_SHARE_ACCESS_TOKEN = "misso:share:at:%s" // Subject, access token as value REDIS_KEY_USER_ACCESS_TOKEN = "misso:user:token:%s" // Subject, access token as value
REDIS_KEY_SHARE_USER_INFO = "misso:share:ui:%s" // Subject, user info as value REDIS_KEY_USER_INFO = "misso:user:info:%s" // Subject, user info as value
) )

View File

@ -1,12 +1,10 @@
package consts package consts
import "time"
const ( const (
TIME_REQUEST_VALID = 1 * time.Hour TIME_DEFAULT_REQUEST_VALID = 3600 // 1 Hour
TIME_LOGIN_REMEMBER = 10 * time.Minute TIME_DEFAULT_LOGIN_REMEMBER = 600 // 10 Minute
TIME_CONSENT_REMEMBER = 0 // Forever TIME_DEFAULT_CONSENT_REMEMBER = 0 // Forever
TIME_USERINFO_CACHE = 10 * time.Minute TIME_DEFAULT_USERINFO_CACHE = 3600 // 1 Hour
) )

View File

@ -5,10 +5,12 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
client "github.com/ory/hydra-client-go/v2" client "github.com/ory/hydra-client-go/v2"
"misso/config"
"misso/consts" "misso/consts"
"misso/global" "misso/global"
"misso/utils" "misso/utils"
"net/http" "net/http"
"time"
) )
func ConsentCheck(ctx *gin.Context) { func ConsentCheck(ctx *gin.Context) {
@ -56,8 +58,8 @@ func ConsentCheck(ctx *gin.Context) {
// Generate CSRF token // Generate CSRF token
global.Logger.Debugf("Generating CSRF token...") global.Logger.Debugf("Generating CSRF token...")
csrf := utils.RandString(32) csrf := utils.RandString(32)
sessKey := fmt.Sprintf(consts.REDIS_KEY_CONSENT_CSRF, csrf) sessKey := fmt.Sprintf(consts.REDIS_KEY_CONSENT_CSRF, oauth2challenge)
err := global.Redis.Set(context.Background(), sessKey, oauth2challenge, consts.TIME_REQUEST_VALID).Err() err := global.Redis.Set(context.Background(), sessKey, csrf, time.Duration(config.Config.Time.RequestValid)*time.Second).Err()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to save csrf into redis with error: %v", err) global.Logger.Errorf("Failed to save csrf into redis with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
@ -69,7 +71,7 @@ func ConsentCheck(ctx *gin.Context) {
// Retrieve context // Retrieve context
global.Logger.Debugf("Retrieving context...") global.Logger.Debugf("Retrieving context...")
userinfoCtx, err := utils.GetUserinfo(*consentReq.Subject) userinfo, err := utils.GetUserinfo(*consentReq.Subject)
if err != nil { if err != nil {
global.Logger.Errorf("Failed to retrieve userinfo with error: %v", err) global.Logger.Errorf("Failed to retrieve userinfo with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
@ -81,9 +83,10 @@ func ConsentCheck(ctx *gin.Context) {
// Show the consent UI // Show the consent UI
global.Logger.Debugf("Rendering consent UI...") global.Logger.Debugf("Rendering consent UI...")
templateFields := gin.H{ templateFields := gin.H{
"user": *userinfoCtx, "user": *userinfo,
"challenge": oauth2challenge, "challenge": oauth2challenge,
"csrf": csrf, "csrf": csrf,
"scopes": consentReq.RequestedScope,
} }
if consentReq.Client.LogoUri != nil && *consentReq.Client.LogoUri != "" { if consentReq.Client.LogoUri != nil && *consentReq.Client.LogoUri != "" {
@ -94,6 +97,12 @@ func ConsentCheck(ctx *gin.Context) {
} else { } else {
templateFields["clientName"] = *consentReq.Client.ClientId templateFields["clientName"] = *consentReq.Client.ClientId
} }
if consentReq.Client.PolicyUri != nil && *consentReq.Client.PolicyUri != "" {
templateFields["clientPolicy"] = *consentReq.Client.PolicyUri
}
if consentReq.Client.TosUri != nil && *consentReq.Client.TosUri != "" {
templateFields["clientTos"] = *consentReq.Client.TosUri
}
ctx.HTML(http.StatusOK, "consent.tmpl", templateFields) ctx.HTML(http.StatusOK, "consent.tmpl", templateFields)
global.Logger.Debugf("User should now see Consent UI.") global.Logger.Debugf("User should now see Consent UI.")

View File

@ -5,16 +5,17 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
client "github.com/ory/hydra-client-go/v2" client "github.com/ory/hydra-client-go/v2"
"misso/config"
"misso/consts" "misso/consts"
"misso/global" "misso/global"
"net/http" "net/http"
"time"
) )
type ConsentConfirmRequest struct { type ConsentConfirmRequest struct {
CSRF string `form:"_csrf"` CSRF string `form:"_csrf"`
Remember bool `form:"remember"` Challenge string `form:"challenge"`
Action string `form:"action"` Remember bool `form:"remember"`
Action string `form:"action"`
} }
func ConsentConfirm(ctx *gin.Context) { func ConsentConfirm(ctx *gin.Context) {
@ -32,16 +33,23 @@ func ConsentConfirm(ctx *gin.Context) {
// Validate CSRF // Validate CSRF
global.Logger.Debugf("Validating CSRF...") global.Logger.Debugf("Validating CSRF...")
sessKey := fmt.Sprintf(consts.REDIS_KEY_CONSENT_CSRF, req.CSRF) sessKey := fmt.Sprintf(consts.REDIS_KEY_CONSENT_CSRF, req.Challenge)
oauth2challenge, err := global.Redis.Get(context.Background(), sessKey).Result() csrfSession, err := global.Redis.Get(context.Background(), sessKey).Result()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to get csrf from redis with error: %v", err) global.Logger.Errorf("Failed to get csrf from redis with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"error": "Failed to get csrf", "error": "Failed to get csrf",
}) })
return return
} else if csrfSession != req.CSRF {
ctx.HTML(http.StatusForbidden, "error.tmpl", gin.H{
"error": "CSRF not match",
})
return
} }
oauth2challenge := req.Challenge
// Delete used challenge // Delete used challenge
global.Redis.Del(context.Background(), sessKey) global.Redis.Del(context.Background(), sessKey)
@ -80,9 +88,9 @@ func ConsentConfirm(ctx *gin.Context) {
global.Logger.Debugf("User accepted the request, reporting back to hydra...") global.Logger.Debugf("User accepted the request, reporting back to hydra...")
global.Logger.Debugf("Initializing ID Token...") global.Logger.Debugf("Initializing ID Token...")
rememberFor := int64(consts.TIME_CONSENT_REMEMBER / time.Second) // Remember forever rememberFor := config.Config.Time.ConsentRemember // Remember forever
acceptReq, _, err := global.Hydra.Admin.OAuth2Api.AcceptOAuth2ConsentRequest(context.Background()).ConsentChallenge(oauth2challenge).AcceptOAuth2ConsentRequest(client.AcceptOAuth2ConsentRequest{ acceptReq, _, err := global.Hydra.Admin.OAuth2Api.AcceptOAuth2ConsentRequest(context.Background()).ConsentChallenge(oauth2challenge).AcceptOAuth2ConsentRequest(client.AcceptOAuth2ConsentRequest{
GrantScope: consentReq.RequestedScope, // TODO: Specify scopes GrantScope: consentReq.RequestedScope,
GrantAccessTokenAudience: consentReq.RequestedAccessTokenAudience, GrantAccessTokenAudience: consentReq.RequestedAccessTokenAudience,
Remember: &req.Remember, Remember: &req.Remember,
RememberFor: &rememberFor, RememberFor: &rememberFor,

View File

@ -5,10 +5,12 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
client "github.com/ory/hydra-client-go/v2" client "github.com/ory/hydra-client-go/v2"
"misso/config"
"misso/consts" "misso/consts"
"misso/global" "misso/global"
"misso/misskey" "misso/misskey"
"net/http" "net/http"
"time"
) )
func Login(ctx *gin.Context) { func Login(ctx *gin.Context) {
@ -69,7 +71,7 @@ func Login(ctx *gin.Context) {
// Save login challenge state into redis (misskey cannot keep state info) // Save login challenge state into redis (misskey cannot keep state info)
sessKey := fmt.Sprintf(consts.REDIS_KEY_LOGIN_SESSION, authSess.Token) sessKey := fmt.Sprintf(consts.REDIS_KEY_LOGIN_SESSION, authSess.Token)
err = global.Redis.Set(context.Background(), sessKey, oauth2challenge, consts.TIME_REQUEST_VALID).Err() err = global.Redis.Set(context.Background(), sessKey, oauth2challenge, time.Duration(config.Config.Time.RequestValid)*time.Second).Err()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to save session into redis with error: %v", err) global.Logger.Errorf("Failed to save session into redis with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{

View File

@ -9,9 +9,7 @@ import (
"misso/consts" "misso/consts"
"misso/global" "misso/global"
"misso/misskey" "misso/misskey"
"misso/utils"
"net/http" "net/http"
"time"
) )
func MisskeyAuthCallback(ctx *gin.Context) { func MisskeyAuthCallback(ctx *gin.Context) {
@ -62,9 +60,10 @@ func MisskeyAuthCallback(ctx *gin.Context) {
return return
} }
userid := fmt.Sprintf("%s@%s", usermeta.User.Username, config.Config.Misskey.Instance) // Save user key
userIdentifier := fmt.Sprintf("%s@%s", usermeta.User.Username, config.Config.Misskey.Instance)
sessAccessTokenKey := fmt.Sprintf(consts.REDIS_KEY_SHARE_ACCESS_TOKEN, userid) sessAccessTokenKey := fmt.Sprintf(consts.REDIS_KEY_USER_ACCESS_TOKEN, userIdentifier)
err = global.Redis.Set(context.Background(), sessAccessTokenKey, usermeta.AccessToken, 0).Err() err = global.Redis.Set(context.Background(), sessAccessTokenKey, usermeta.AccessToken, 0).Err()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to save session access token into redis with error: %v", err) global.Logger.Errorf("Failed to save session access token into redis with error: %v", err)
@ -74,24 +73,16 @@ func MisskeyAuthCallback(ctx *gin.Context) {
return return
} }
// Save context into redis
err = utils.SaveUserinfo(userid, &usermeta.User)
if err != nil {
global.Logger.Errorf("Failed to save session user info into redis with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"error": "Failed to save userinfo",
})
return
}
global.Logger.Debugf("User accepted the request, reporting back to hydra...") global.Logger.Debugf("User accepted the request, reporting back to hydra...")
remember := true acceptReq := client.AcceptOAuth2LoginRequest{
rememberFor := int64(consts.TIME_LOGIN_REMEMBER / time.Second) Subject: userIdentifier,
acceptReq, _, err := global.Hydra.Admin.OAuth2Api.AcceptOAuth2LoginRequest(context.Background()).LoginChallenge(oauth2challenge).AcceptOAuth2LoginRequest(client.AcceptOAuth2LoginRequest{ }
Subject: userid, if config.Config.Time.LoginRemember > 0 {
Remember: &remember, remember := true
RememberFor: &rememberFor, acceptReq.Remember = &remember
}).Execute() acceptReq.RememberFor = &config.Config.Time.LoginRemember
}
acceptRes, _, err := global.Hydra.Admin.OAuth2Api.AcceptOAuth2LoginRequest(context.Background()).LoginChallenge(oauth2challenge).AcceptOAuth2LoginRequest(acceptReq).Execute()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to accept login request with error: %v", err) global.Logger.Errorf("Failed to accept login request with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
@ -101,7 +92,7 @@ func MisskeyAuthCallback(ctx *gin.Context) {
} }
// Redirect to target uri // Redirect to target uri
ctx.Redirect(http.StatusTemporaryRedirect, acceptReq.RedirectTo) ctx.Redirect(http.StatusTemporaryRedirect, acceptRes.RedirectTo)
global.Logger.Debugf("User should now be redirecting to target URI.") global.Logger.Debugf("User should now be redirecting to target URI.")

View File

@ -4,17 +4,11 @@ import (
"context" "context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"misso/global" "misso/global"
"misso/types"
"misso/utils" "misso/utils"
"net/http" "net/http"
"strings" "strings"
) )
type UserinfoResponse struct {
types.MisskeyUser
EMail string `json:"email"`
}
func UserInfo(ctx *gin.Context) { func UserInfo(ctx *gin.Context) {
// Get token from header // Get token from header
accessToken := strings.Replace(ctx.GetHeader("Authorization"), "Bearer ", "", 1) accessToken := strings.Replace(ctx.GetHeader("Authorization"), "Bearer ", "", 1)
@ -45,7 +39,7 @@ func UserInfo(ctx *gin.Context) {
// Return user info // Return user info
global.Logger.Debugf("Retrieving context...") global.Logger.Debugf("Retrieving context...")
userinfoCtx, err := utils.GetUserinfo(*tokenInfo.Sub) userinfo, err := utils.GetUserinfo(*tokenInfo.Sub)
if err != nil { if err != nil {
global.Logger.Errorf("Failed to retrieve userinfo with error: %v", err) global.Logger.Errorf("Failed to retrieve userinfo with error: %v", err)
ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ ctx.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
@ -54,9 +48,26 @@ func UserInfo(ctx *gin.Context) {
return return
} }
ctx.JSON(http.StatusOK, UserinfoResponse{ userinfoRes := gin.H{} // map[string]interface{}
MisskeyUser: *userinfoCtx,
EMail: *tokenInfo.Sub, // Get scopes
}) if tokenInfo.Scope != nil && *tokenInfo.Scope != "" {
// Has scopes
scopes := strings.Split(*tokenInfo.Scope, " ")
for _, s := range scopes {
if value, ok := (*userinfo)[s]; ok {
userinfoRes[s] = value
}
if s == "openid" {
userinfoRes["sub"] = *tokenInfo.Sub
} else if s == "username" {
// Add "nickname" field for OIDC compatibility
userinfoRes["nickname"] = userinfoRes[s]
}
}
}
ctx.JSON(http.StatusOK, userinfoRes)
} }

View File

@ -3,6 +3,7 @@ package inits
import ( import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"misso/config" "misso/config"
"misso/consts"
"os" "os"
) )
@ -23,5 +24,21 @@ func Config() error {
return err return err
} }
// Validate time
if config.Config.Time.RequestValid <= 0 {
config.Config.Time.RequestValid = consts.TIME_DEFAULT_REQUEST_VALID
}
if config.Config.Time.LoginRemember < 0 {
// 0 means don't remember (in extreme account switch situations)
config.Config.Time.LoginRemember = consts.TIME_DEFAULT_LOGIN_REMEMBER
}
if config.Config.Time.ConsentRemember < 0 {
// 0 means remember forever (default behavior)
config.Config.Time.ConsentRemember = consts.TIME_DEFAULT_CONSENT_REMEMBER
}
if config.Config.Time.UserinfoCache <= 0 {
config.Config.Time.UserinfoCache = consts.TIME_DEFAULT_USERINFO_CACHE
}
return nil return nil
} }

View File

@ -22,7 +22,7 @@ func PostAPIRequest[T I_Response | AuthSessionGenerate_Response | AuthSessionUse
apiEndpointPath string, reqBody any, apiEndpointPath string, reqBody any,
) (*T, error) { ) (*T, error) {
// Prepare request // Prepare request
apiEndpoint := fmt.Sprintf("https://%s%s", config.Config.Misskey.Instance, apiEndpointPath) apiEndpoint := fmt.Sprintf("https://%s/api/%s", config.Config.Misskey.Instance, apiEndpointPath)
reqBodyBytes, err := json.Marshal(reqBody) reqBodyBytes, err := json.Marshal(reqBody)
if err != nil { if err != nil {

View File

@ -13,7 +13,7 @@ type AuthSessionGenerate_Response struct {
func GenerateAuthSession() (*AuthSessionGenerate_Response, error) { func GenerateAuthSession() (*AuthSessionGenerate_Response, error) {
return PostAPIRequest[AuthSessionGenerate_Response]("/api/auth/session/generate", &AuthSessionGenerate_Request{ return PostAPIRequest[AuthSessionGenerate_Response]("auth/session/generate", &AuthSessionGenerate_Request{
AppSecret: config.Config.Misskey.Application.Secret, AppSecret: config.Config.Misskey.Application.Secret,
}) })

View File

@ -9,7 +9,7 @@ type I_Request struct {
type I_Response = types.MisskeyUser type I_Response = types.MisskeyUser
func GetUserinfo(accessToken string) (*I_Response, error) { func GetUserinfo(accessToken string) (*I_Response, error) {
return PostAPIRequest[I_Response]("/api/i", &I_Request{ return PostAPIRequest[I_Response]("i", &I_Request{
I: accessToken, I: accessToken,
}) })
} }

View File

@ -11,13 +11,13 @@ type AuthSessionUserkey_Request struct {
} }
type AuthSessionUserkey_Response struct { type AuthSessionUserkey_Response struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
User types.MisskeyUser `json:"user"` User types.MisskeyUserBase `json:"user"`
} }
func GetUserkey(token string) (*AuthSessionUserkey_Response, error) { func GetUserkey(token string) (*AuthSessionUserkey_Response, error) {
return PostAPIRequest[AuthSessionUserkey_Response]("/api/auth/session/userkey", &AuthSessionUserkey_Request{ return PostAPIRequest[AuthSessionUserkey_Response]("auth/session/userkey", &AuthSessionUserkey_Request{
AppSecret: config.Config.Misskey.Application.Secret, AppSecret: config.Config.Misskey.Application.Secret,
Token: token, Token: token,
}) })

View File

@ -1,11 +1,60 @@
{{ define "consent.tmpl" }} {{ define "consent.tmpl" }}
<html> <html lang="zh">
<head> <head>
<title>授权确认</title> <title>Confirm authorization</title>
{{ template "head.tmpl" }} {{ template "head.tmpl" }}
<style> <style>
ul.scopes,
ul.app-terms {
list-style: none;
padding-left: 0;
}
ul.scopes {
text-align: left;
width: 100%;
max-height: 30vh;
overflow-y: auto;
}
ul.scopes > li {
padding: 6px 12px;
margin: 6px 0;
border-radius: 6px;
background-color: #282c34;
}
ul.scopes > li > * {
cursor: pointer;
}
ul.app-terms {
display: inline-flex;
gap: 0;
}
ul.app-terms > li {
padding: 0 6px;
margin: 0;
}
ul.app-terms > li:not(:last-child) {
border-right: 1px solid white;
}
ul.app-terms > li > a {
color: #62b6e7;
}
.consent-notice {
width: 100%;
border-radius: 6px;
background-color: #282c34;
}
p.remember { p.remember {
display: flex; display: flex;
justify-content: center;
} }
p.remember, p.remember > * { p.remember, p.remember > * {
cursor: pointer; cursor: pointer;
@ -59,7 +108,7 @@
background: #15803d; background: #15803d;
} }
#app-name, #user-name { .app-name, .user-name {
color: #62b6e7; color: #62b6e7;
} }
</style> </style>
@ -67,29 +116,69 @@
<body> <body>
<form id="main" action="/consent" method="POST"> <form id="main" action="/consent" method="POST">
<input type="hidden" name="_csrf" value="{{ .csrf }}" /> <input type="hidden" name="_csrf" value="{{ .csrf }}" />
<input type="hidden" name="challenge" value="{{ .challenge }}" />
{{ if .logo }} <div class="logo">
<img src="{{ .logo }}" alt="{{ .clientName }}" width="120" height="120" /> {{ if .logo }}
{{ else }} <img src="{{ .logo }}" alt="{{ .clientName }}" width="120" height="120" />
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48"><title>c-warning</title><g><path fill="#EFD358" d="M24,1C11.31787,1,1,11.31787,1,24s10.31787,23,23,23s23-10.31787,23-23S36.68213,1,24,1z"></path> <path fill="#FFFFFF" d="M24,28c0.55225,0,1-0.44775,1-1V14c0-0.55225-0.44775-1-1-1s-1,0.44775-1,1v13 C23,27.55225,23.44775,28,24,28z"></path> <circle fill="#FFFFFF" cx="24" cy="33" r="2"></circle></g></svg> {{ else }}
{{ end }} <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48">
<g>
<circle fill="#EFD358" cx="24" cy="24" r="24"></circle>
<path fill="#FFFFFF" d="M24,28c0.55225,0,1-0.44775,1-1V14c0-0.55225-0.44775-1-1-1s-1,0.44775-1,1v13 C23,27.55225,23.44775,28,24,28z"></path>
<circle fill="#FFFFFF" cx="24" cy="33" r="2"></circle>
</g>
</svg>
{{ end }}
</div>
<p> <div>
应用程序 <p>
<span id="app-name">{{ .clientName }}</span> Application
正请求读取 <span class="app-name">{{ .clientName }}</span>
<span id="user-name">{{ .user.Name }}</span> is requesting
的信息 <br />
</p> access to account
<span class="user-name">{{ .user.name }}</span>
with the following scopes:
</p>
<p class="remember"> <ul class="scopes">
<input type="checkbox" id="remember" name="remember" value="true" /> {{ $user := .user }}
<label for="remember">记住我的选择</label> {{ range $scope := .scopes }}
</p> <li>
<details>
<summary>{{ $scope }}</summary>
<pre>{{ index $user $scope }}</pre>
</details>
</li>
{{ end }}
</ul>
{{ if or .clientPolicy .clientTos }}
<ul class="app-terms">
{{ if .clientPolicy }}
<li><a href="{{ .clientPolicy }}" target="_blank" referrerpolicy="no-referrer">Client Usage Policy</a></li>
{{ end }}
{{ if .clientTos }}
<li><a href="{{ .clientTos }}" target="_blank" referrerpolicy="no-referrer">Terms of Service</a></li>
{{ end }}
</ul>
{{ end }}
</div>
<div class="consent-notice">
<p>Accept the request?</p>
<p class="remember">
<input type="checkbox" id="remember" name="remember" value="true" />
<label for="remember">Remember my choice</label>
</p>
</div>
<p class="buttons"> <p class="buttons">
<button type="submit" name="action" value="reject" class="reject">拒绝</button> <button type="submit" name="action" value="reject" class="reject">Reject</button>
<button type="submit" name="action" value="accept" class="accept">接受</button> <button type="submit" name="action" value="accept" class="accept">Accept</button>
</p> </p>
</form> </form>
</body> </body>

View File

@ -1,14 +1,30 @@
{{ define "error.tmpl" }} {{ define "error.tmpl" }}
<html> <html lang="zh">
<head> <head>
<title>出错了</title> <title>Error</title>
{{ template "head.tmpl" }} {{ template "head.tmpl" }}
<style>
.error {
width: 100%;
padding: 20px 0;
border-radius: 6px;
background-color: #282c34;
user-select: all;
}
</style>
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48"><title>c-remove</title><g><path fill="#E86C60" d="M24,47C11.31787,47,1,36.68262,1,24S11.31787,1,24,1s23,10.31738,23,23S36.68213,47,24,47z"></path> <path fill="#FFFFFF" d="M25.41406,24l7.29297-7.29297c0.39062-0.39062,0.39062-1.02344,0-1.41406s-1.02344-0.39062-1.41406,0 L24,22.58594l-7.29297-7.29297c-0.39062-0.39062-1.02344-0.39062-1.41406,0s-0.39062,1.02344,0,1.41406L22.58594,24 l-7.29297,7.29297c-0.39062,0.39062-0.39062,1.02344,0,1.41406C15.48828,32.90234,15.74414,33,16,33 s0.51172-0.09766,0.70703-0.29297L24,25.41406l7.29297,7.29297C31.48828,32.90234,31.74414,33,32,33 s0.51172-0.09766,0.70703-0.29297c0.39062-0.39062,0.39062-1.02344,0-1.41406L25.41406,24z"></path></g></svg> <div class="logo">
<h1>发生了一些错误</h1> <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48">
<p>{{ .error }}</p> <g>
<circle fill="#E86C60" cx="24" cy="24" r="24"></circle>
<path fill="#FFFFFF" d="M25.41406,24l7.29297-7.29297c0.39062-0.39062,0.39062-1.02344,0-1.41406s-1.02344-0.39062-1.41406,0 L24,22.58594l-7.29297-7.29297c-0.39062-0.39062-1.02344-0.39062-1.41406,0s-0.39062,1.02344,0,1.41406L22.58594,24 l-7.29297,7.29297c-0.39062,0.39062-0.39062,1.02344,0,1.41406C15.48828,32.90234,15.74414,33,16,33 s0.51172-0.09766,0.70703-0.29297L24,25.41406l7.29297,7.29297C31.48828,32.90234,31.74414,33,32,33 s0.51172-0.09766,0.70703-0.29297c0.39062-0.39062,0.39062-1.02344,0-1.41406L25.41406,24z"></path>
</g>
</svg>
</div>
<h1>An error happened</h1>
<p class="error">{{ .error }}</p>
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,10 +1,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<style> <style>
html, body {
height: 100%;
width: 100%;
margin: 0;
}
body { body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #282c34; background-color: #282c34;
} }
#main { #main {
@ -13,12 +19,23 @@
text-align: center; text-align: center;
align-items: center; align-items: center;
color: white; color: white;
padding: 40px; padding: 80px 40px 40px 40px;
background-color: #21252b; background-color: #21252b;
border-radius: 12px; border-radius: 12px;
margin: 0 40px; margin: 0 40px;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
word-break: break-word; word-break: break-word;
position: relative;
}
.logo {
position: absolute;
border: 10px solid #282c34;
top: -70px;
}
.logo, .logo > * {
border-radius: 120px;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
{{ define "index.tmpl" }} {{ define "index.tmpl" }}
<html> <html lang="zh">
<head> <head>
<title>MiSSO</title> <title>MiSSO</title>
{{ template "head.tmpl" }} {{ template "head.tmpl" }}
@ -24,13 +24,21 @@
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48"><title>access-key</title><rect data-element="frame" x="0" y="0" width="48" height="48" rx="48" ry="48" stroke="none" fill="#62b6e7"></rect><g transform="translate(9.600000000000001 9.600000000000001) scale(0.6)"><path d="M38,23a1,1,0,0,1-.707-.293l-6-6a1,1,0,0,1,0-1.414l8-8a1,1,0,0,1,1.414,0l6,6a1,1,0,0,1,0,1.414l-2,2a1,1,0,0,1-1.414,0L41,14.414,38.414,17l2.293,2.293a1,1,0,0,1,0,1.414l-2,2A1,1,0,0,1,38,23Z" fill="#eba40a"></path><path d="M44.061,3.939a1.5,1.5,0,0,0-2.122,0L17.923,27.956a10.027,10.027,0,1,0,2.121,2.121L44.061,6.061A1.5,1.5,0,0,0,44.061,3.939ZM12,43a7,7,0,1,1,4.914-11.978c.011.012.014.027.025.039s.027.014.039.025A6.995,6.995,0,0,1,12,43Z" fill="#ffd764"></path></g></svg> <div class="logo">
<h1>欢迎来到 MiSSO</h1> <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 48 48">
<circle fill="#62b6e7" cx="24" cy="24" r="24"></circle>
<g transform="translate(9.600000000000001 9.600000000000001) scale(0.6)">
<path d="M38,23a1,1,0,0,1-.707-.293l-6-6a1,1,0,0,1,0-1.414l8-8a1,1,0,0,1,1.414,0l6,6a1,1,0,0,1,0,1.414l-2,2a1,1,0,0,1-1.414,0L41,14.414,38.414,17l2.293,2.293a1,1,0,0,1,0,1.414l-2,2A1,1,0,0,1,38,23Z" fill="#eba40a"></path>
<path d="M44.061,3.939a1.5,1.5,0,0,0-2.122,0L17.923,27.956a10.027,10.027,0,1,0,2.121,2.121L44.061,6.061A1.5,1.5,0,0,0,44.061,3.939ZM12,43a7,7,0,1,1,4.914-11.978c.011.012.014.027.025.039s.027.014.039.025A6.995,6.995,0,0,1,12,43Z" fill="#ffd764"></path>
</g>
</svg>
</div>
<h1>Welcome to MiSSO</h1>
<p> <p>
直接访问这里好像不太对哦 You should not access this page directly.
</p> </p>
<p> <p>
<a class="primary" href="https://docs.nya.one/peripheral/misso/">查看文档</a> <a class="primary" href="https://docs.nya.one/peripheral/misso/">Documentation</a>
</p> </p>
</div> </div>
</body> </body>

View File

@ -14,4 +14,10 @@ type Config struct {
Hydra struct { Hydra struct {
AdminUrl string `yaml:"admin_url"` AdminUrl string `yaml:"admin_url"`
} `yaml:"hydra"` } `yaml:"hydra"`
Time struct {
RequestValid int64 `yaml:"request_valid"`
LoginRemember int64 `yaml:"login_remember"`
ConsentRemember int64 `yaml:"consent_remember"`
UserinfoCache int64 `yaml:"userinfo_cache"`
} `yaml:"time"`
} }

View File

@ -1,186 +1,8 @@
package types package types
import "time" type MisskeyUserBase struct {
Username string `json:"username"`
type MisskeyUser struct { // Ignore other fields
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Host interface{} `json:"host"`
AvatarUrl string `json:"avatarUrl"`
AvatarBlurhash string `json:"avatarBlurhash"`
IsBot bool `json:"isBot"`
IsCat bool `json:"isCat"`
OnlineStatus string `json:"onlineStatus"`
Url interface{} `json:"url"`
Uri interface{} `json:"uri"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastFetchedAt interface{} `json:"lastFetchedAt"`
BannerUrl string `json:"bannerUrl"`
BannerBlurhash string `json:"bannerBlurhash"`
IsLocked bool `json:"isLocked"`
IsSilenced bool `json:"isSilenced"`
IsSuspended bool `json:"isSuspended"`
Description string `json:"description"`
Location string `json:"location"`
Birthday string `json:"birthday"`
Lang string `json:"lang"`
Fields []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"fields"`
FollowersCount int `json:"followersCount"`
FollowingCount int `json:"followingCount"`
NotesCount int `json:"notesCount"`
PinnedNoteIds []string `json:"pinnedNoteIds"`
PinnedNotes []struct {
Id string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UserId string `json:"userId"`
User struct {
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Host interface{} `json:"host"`
AvatarUrl string `json:"avatarUrl"`
AvatarBlurhash string `json:"avatarBlurhash"`
IsBot bool `json:"isBot"`
IsCat bool `json:"isCat"`
OnlineStatus string `json:"onlineStatus"`
} `json:"user"`
Text string `json:"text"`
Cw *string `json:"cw"`
Visibility string `json:"visibility"`
LocalOnly bool `json:"localOnly"`
RenoteCount int `json:"renoteCount"`
RepliesCount int `json:"repliesCount"`
Reactions map[string]int `json:"reactions"`
FileIds []string `json:"fileIds"`
Files []struct {
Id string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name"`
Type string `json:"type"`
Md5 string `json:"md5"`
Size int `json:"size"`
IsSensitive bool `json:"isSensitive"`
Blurhash string `json:"blurhash"`
Properties struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"properties"`
Url string `json:"url"`
ThumbnailUrl string `json:"thumbnailUrl"`
Comment interface{} `json:"comment"`
FolderId interface{} `json:"folderId"`
Folder interface{} `json:"folder"`
UserId interface{} `json:"userId"`
User interface{} `json:"user"`
} `json:"files"`
ReplyId interface{} `json:"replyId"`
RenoteId interface{} `json:"renoteId"`
MyReaction string `json:"myReaction,omitempty"`
} `json:"pinnedNotes"`
PinnedPageId string `json:"pinnedPageId"`
PinnedPage struct {
Id string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UserId string `json:"userId"`
User struct {
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Host interface{} `json:"host"`
AvatarUrl string `json:"avatarUrl"`
AvatarBlurhash string `json:"avatarBlurhash"`
IsBot bool `json:"isBot"`
IsCat bool `json:"isCat"`
OnlineStatus string `json:"onlineStatus"`
} `json:"user"`
Content []struct {
Id string `json:"id"`
Text string `json:"text,omitempty"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
} `json:"content"`
Variables []interface{} `json:"variables"`
Title string `json:"title"`
Name string `json:"name"`
Summary string `json:"summary"`
HideTitleWhenPinned bool `json:"hideTitleWhenPinned"`
AlignCenter bool `json:"alignCenter"`
Font string `json:"font"`
Script string `json:"script"`
EyeCatchingImageId string `json:"eyeCatchingImageId"`
EyeCatchingImage struct {
Id string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name"`
Type string `json:"type"`
Md5 string `json:"md5"`
Size int `json:"size"`
IsSensitive bool `json:"isSensitive"`
Blurhash string `json:"blurhash"`
Properties struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"properties"`
Url string `json:"url"`
ThumbnailUrl string `json:"thumbnailUrl"`
Comment interface{} `json:"comment"`
FolderId interface{} `json:"folderId"`
Folder interface{} `json:"folder"`
UserId interface{} `json:"userId"`
User interface{} `json:"user"`
} `json:"eyeCatchingImage"`
AttachedFiles []interface{} `json:"attachedFiles"`
LikedCount int `json:"likedCount"`
IsLiked bool `json:"isLiked"`
} `json:"pinnedPage"`
PublicReactions bool `json:"publicReactions"`
FfVisibility string `json:"ffVisibility"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
UsePasswordLessLogin bool `json:"usePasswordLessLogin"`
SecurityKeys bool `json:"securityKeys"`
Roles []struct {
Id string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
IsModerator bool `json:"isModerator"`
IsAdministrator bool `json:"isAdministrator"`
} `json:"roles"`
AvatarId string `json:"avatarId"`
BannerId string `json:"bannerId"`
IsModerator bool `json:"isModerator"`
IsAdmin bool `json:"isAdmin"`
InjectFeaturedNote bool `json:"injectFeaturedNote"`
ReceiveAnnouncementEmail bool `json:"receiveAnnouncementEmail"`
AlwaysMarkNsfw bool `json:"alwaysMarkNsfw"`
AutoSensitive bool `json:"autoSensitive"`
CarefulBot bool `json:"carefulBot"`
AutoAcceptFollowed bool `json:"autoAcceptFollowed"`
NoCrawle bool `json:"noCrawle"`
IsExplorable bool `json:"isExplorable"`
IsDeleted bool `json:"isDeleted"`
HideOnlineStatus bool `json:"hideOnlineStatus"`
HasUnreadSpecifiedNotes bool `json:"hasUnreadSpecifiedNotes"`
HasUnreadMentions bool `json:"hasUnreadMentions"`
HasUnreadAnnouncement bool `json:"hasUnreadAnnouncement"`
HasUnreadAntenna bool `json:"hasUnreadAntenna"`
HasUnreadChannel bool `json:"hasUnreadChannel"`
HasUnreadMessagingMessage bool `json:"hasUnreadMessagingMessage"`
HasUnreadNotification bool `json:"hasUnreadNotification"`
HasPendingReceivedFollowRequest bool `json:"hasPendingReceivedFollowRequest"`
Integrations struct {
} `json:"integrations"`
MutedWords [][]string `json:"mutedWords"`
MutedInstances []string `json:"mutedInstances"`
MutingNotificationTypes []string `json:"mutingNotificationTypes"`
EmailNotificationTypes []string `json:"emailNotificationTypes"`
ShowTimelineReplies bool `json:"showTimelineReplies"`
} }
// TODO: Find a better way to split necessary fields and additional fields type MisskeyUser = map[string]any // Just raw json map

View File

@ -13,7 +13,7 @@ import (
func GetUserinfo(subject string) (*types.MisskeyUser, error) { func GetUserinfo(subject string) (*types.MisskeyUser, error) {
// Check cache key // Check cache key
global.Logger.Debugf("Checking userinfo cache...") global.Logger.Debugf("Checking userinfo cache...")
userinfoCacheKey := fmt.Sprintf(consts.REDIS_KEY_SHARE_USER_INFO, subject) userinfoCacheKey := fmt.Sprintf(consts.REDIS_KEY_USER_INFO, subject)
exist, err := global.Redis.Exists(context.Background(), userinfoCacheKey).Result() exist, err := global.Redis.Exists(context.Background(), userinfoCacheKey).Result()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to check userinfo exist status with error: %v", err) global.Logger.Errorf("Failed to check userinfo exist status with error: %v", err)
@ -38,7 +38,7 @@ func GetUserinfo(subject string) (*types.MisskeyUser, error) {
// Fallback to get info directly, we need user's access token. // Fallback to get info directly, we need user's access token.
global.Logger.Debugf("No cached userinfo found (or valid), trying to get latest response.") global.Logger.Debugf("No cached userinfo found (or valid), trying to get latest response.")
accessTokenCacheKey := fmt.Sprintf(consts.REDIS_KEY_SHARE_ACCESS_TOKEN, subject) accessTokenCacheKey := fmt.Sprintf(consts.REDIS_KEY_USER_ACCESS_TOKEN, subject)
accessToken, err := global.Redis.Get(context.Background(), accessTokenCacheKey).Result() accessToken, err := global.Redis.Get(context.Background(), accessTokenCacheKey).Result()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to get user access token with error: %v", err) global.Logger.Errorf("Failed to get user access token with error: %v", err)
@ -52,6 +52,9 @@ func GetUserinfo(subject string) (*types.MisskeyUser, error) {
return nil, err return nil, err
} }
// Append subject as email to userinfo
(*userinfo)["email"] = subject
// Save userinfo into redis // Save userinfo into redis
_ = SaveUserinfo(subject, userinfo) // Ignore errors _ = SaveUserinfo(subject, userinfo) // Ignore errors

View File

@ -4,9 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"misso/config"
"misso/consts" "misso/consts"
"misso/global" "misso/global"
"misso/types" "misso/types"
"time"
) )
func SaveUserinfo(subject string, userinfo *types.MisskeyUser) error { func SaveUserinfo(subject string, userinfo *types.MisskeyUser) error {
@ -15,8 +17,8 @@ func SaveUserinfo(subject string, userinfo *types.MisskeyUser) error {
global.Logger.Errorf("Failed to parse accept context with error: %v", err) global.Logger.Errorf("Failed to parse accept context with error: %v", err)
return err return err
} }
sessUserInfoKey := fmt.Sprintf(consts.REDIS_KEY_SHARE_USER_INFO, subject) sessUserInfoKey := fmt.Sprintf(consts.REDIS_KEY_USER_INFO, subject)
err = global.Redis.Set(context.Background(), sessUserInfoKey, userinfoBytes, consts.TIME_USERINFO_CACHE).Err() err = global.Redis.Set(context.Background(), sessUserInfoKey, userinfoBytes, time.Duration(config.Config.Time.UserinfoCache)*time.Second).Err()
if err != nil { if err != nil {
global.Logger.Errorf("Failed to save session user info into redis with error: %v", err) global.Logger.Errorf("Failed to save session user info into redis with error: %v", err)
return err return err