Compare commits

...

6 Commits

Author SHA1 Message Date
d4ea8787c9 fix(123): upload file size that less than 16 MB (close #4816) 2023-07-21 14:35:18 +08:00
a4de04528a fix(123): auth-key verification (close #4811 in #4814)
Co-authored-by: Andy Hsu <i@nn.ci>
2023-07-21 14:33:45 +08:00
f60aae7499 chore(deps): update actions-cool/issues-helper action to v3.5.0 (#4801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-21 13:55:16 +08:00
de8f9e9eee feat: SSO auto register (close #4692 in #4795)
Co-authored-by: Andy Hsu <i@nn.ci>
2023-07-20 16:30:30 +08:00
cace9db12f docs: add Japanese README [skip ci] (#4798) 2023-07-19 14:05:41 +08:00
ec2fb82836 chore: update special sponsors [skip ci] 2023-07-18 15:26:03 +08:00
10 changed files with 390 additions and 156 deletions

View File

@ -10,7 +10,7 @@ jobs:
if: github.event.label.name == 'question' if: github.event.label.name == 'question'
steps: steps:
- name: Create comment - name: Create comment
uses: actions-cool/issues-helper@v3.4.0 uses: actions-cool/issues-helper@v3.5.0
with: with:
actions: 'create-comment' actions: 'create-comment'
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -39,7 +39,7 @@
--- ---
English | [中文](./README_cn.md) | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md) English | [中文](./README_cn.md)| [日本語](./README_ja.md) | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md)
## Features ## Features
@ -112,8 +112,7 @@ https://alist.nn.ci/guide/sponsor.html
### Special sponsors ### Special sponsors
- [亚洲云 - 高防服务器|服务器租用|福州高防|广东电信|香港服务器|美国服务器|海外服务器 - 国内靠谱的企业级云计算服务提供商](https://www.asiayun.com/aff/QQCOOQKZ) (sponsored Chinese API server) - [亚洲云 - 高防服务器|服务器租用|福州高防|广东电信|香港服务器|美国服务器|海外服务器 - 国内靠谱的企业级云计算服务提供商](https://www.asiayun.com/aff/QQCOOQKZ) (sponsored Chinese API server)
- [找资源 - 阿里云盘资源搜索引擎](https://zhaoziyuan.la/) - [找资源 - 阿里云盘资源搜索引擎](https://zhaoziyuan.pw/)
- [KinhDown 百度云盘不限速下载永久免费已稳定运行3年非常可靠Q群 -> 786799372](https://kinhdown.com)
- [JetBrains: Essential tools for software developers and teams](https://www.jetbrains.com/) - [JetBrains: Essential tools for software developers and teams](https://www.jetbrains.com/)
## Contributors ## Contributors

View File

@ -39,7 +39,7 @@
--- ---
[English](./README.md) | 中文 | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md) [English](./README.md) | 中文 | [日本語](./README_ja.md) | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md)
## 功能 ## 功能
@ -110,8 +110,7 @@ AList 是一个开源软件,如果你碰巧喜欢这个项目,并希望我
### 特别赞助 ### 特别赞助
- [亚洲云 - 高防服务器|服务器租用|福州高防|广东电信|香港服务器|美国服务器|海外服务器 - 国内靠谱的企业级云计算服务提供商](https://www.asiayun.com/aff/QQCOOQKZ) (国内API服务器赞助) - [亚洲云 - 高防服务器|服务器租用|福州高防|广东电信|香港服务器|美国服务器|海外服务器 - 国内靠谱的企业级云计算服务提供商](https://www.asiayun.com/aff/QQCOOQKZ) (国内API服务器赞助)
- [找资源 - 阿里云盘资源搜索引擎](https://zhaoziyuan.la/) - [找资源 - 阿里云盘资源搜索引擎](https://zhaoziyuan.pw/)
- [KinhDown 百度云盘不限速下载永久免费已稳定运行3年非常可靠Q群 -> 786799372](https://kinhdown.com)
- [JetBrains: Essential tools for software developers and teams](https://www.jetbrains.com/) - [JetBrains: Essential tools for software developers and teams](https://www.jetbrains.com/)
## 贡献者 ## 贡献者

137
README_ja.md Normal file
View File

@ -0,0 +1,137 @@
<div align="center">
<a href="https://alist.nn.ci"><img height="100px" alt="logo" src="https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg"/></a>
<p><em>🗂Gin と Solidjs による、複数のストレージをサポートするファイルリストプログラム。</em></p>
<div>
<a href="https://goreportcard.com/report/github.com/alist-org/alist/v3">
<img src="https://goreportcard.com/badge/github.com/alist-org/alist/v3" alt="latest version" />
</a>
<a href="https://github.com/Xhofe/alist/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/Xhofe/alist" alt="License" />
</a>
<a href="https://github.com/Xhofe/alist/actions?query=workflow%3ABuild">
<img src="https://img.shields.io/github/actions/workflow/status/Xhofe/alist/build.yml?branch=main" alt="Build status" />
</a>
<a href="https://github.com/Xhofe/alist/releases">
<img src="https://img.shields.io/github/release/Xhofe/alist" alt="latest version" />
</a>
<a title="Crowdin" target="_blank" href="https://crwd.in/alist">
<img src="https://badges.crowdin.net/alist/localized.svg">
</a>
</div>
<div>
<a href="https://github.com/Xhofe/alist/discussions">
<img src="https://img.shields.io/github/discussions/Xhofe/alist?color=%23ED8936" alt="discussions" />
</a>
<a href="https://discord.gg/F4ymsH4xv2">
<img src="https://img.shields.io/discord/1018870125102895134?logo=discord" alt="discussions" />
</a>
<a href="https://github.com/Xhofe/alist/releases">
<img src="https://img.shields.io/github/downloads/Xhofe/alist/total?color=%239F7AEA&logo=github" alt="Downloads" />
</a>
<a href="https://hub.docker.com/r/xhofe/alist">
<img src="https://img.shields.io/docker/pulls/xhofe/alist?color=%2348BB78&logo=docker&label=pulls" alt="Downloads" />
</a>
<a href="https://alist.nn.ci/guide/sponsor.html">
<img src="https://img.shields.io/badge/%24-sponsor-F87171.svg" alt="sponsor" />
</a>
</div>
</div>
---
[English](./README.md) | [中文](./README_cn.md) | 日本語 | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md)
## 特徴
- [x] マルチストレージ
- [x] ローカルストレージ
- [x] [Aliyundrive](https://www.aliyundrive.com/)
- [x] OneDrive / Sharepoint ([グローバル](https://www.office.com/), [cn](https://portal.partner.microsoftonline.cn),de,us)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com/)
- [x] [123pan](https://www.123pan.com/)
- [x] FTP / SFTP
- [x] [PikPak](https://www.mypikpak.com/)
- [x] [S3](https://aws.amazon.com/s3/)
- [x] [Seafile](https://seafile.com/)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] WebDav(Support OneDrive/SharePoint without API)
- [x] Teambition([China](https://www.teambition.com/ ),[International](https://us.teambition.com/ ))
- [x] [Mediatrack](https://www.mediatrack.cn/)
- [x] [139yun](https://yun.139.com/) (Personal, Family)
- [x] [YandexDisk](https://disk.yandex.com/)
- [x] [BaiduNetdisk](http://pan.baidu.com/)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com/)
- [x] [Aliyundrive share](https://www.aliyundrive.com/)
- [x] [Google photo](https://photos.google.com/)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com/)
- [x] SMB
- [x] [115](https://115.com/)
- [X] Cloudreve
- [x] [Dropbox](https://www.dropbox.com/)
- [x] デプロイが簡単で、すぐに使える
- [x] ファイルプレビュー (PDF, マークダウン, コード, プレーンテキスト, ...)
- [x] ギャラリーモードでの画像プレビュー
- [x] ビデオとオーディオのプレビュー、歌詞と字幕のサポート
- [x] Office ドキュメントのプレビュー (docx, pptx, xlsx, ...)
- [x] `README.md` のプレビューレンダリング
- [x] ファイルのパーマリンクコピーと直接ダウンロード
- [x] ダークモード
- [x] 国際化
- [x] 保護されたルート (パスワード保護と認証)
- [x] WebDav (詳細は https://alist.nn.ci/guide/webdav.html を参照)
- [x] [Docker デプロイ](https://hub.docker.com/r/xhofe/alist)
- [x] Cloudflare ワーカープロキシ
- [x] ファイル/フォルダパッケージのダウンロード
- [x] ウェブアップロード(訪問者にアップロードを許可できる), 削除, mkdir, 名前変更, 移動, コピー
- [x] オフラインダウンロード
- [x] 二つのストレージ間でファイルをコピー
## ドキュメント
<https://alist.nn.ci/>
## デモ
<https://al.nn.ci>
## ディスカッション
一般的なご質問は[ディスカッションフォーラム](https://github.com/Xhofe/alist/discussions)をご利用ください。**問題はバグレポートと機能リクエストのみです。**
## スポンサー
AList はオープンソースのソフトウェアです。もしあなたがこのプロジェクトを気に入ってくださり、続けて欲しいと思ってくださるなら、ぜひスポンサーになってくださるか、1口でも寄付をしてくださるようご検討くださいすべての愛とサポートに感謝します:
https://alist.nn.ci/guide/sponsor.html
### スペシャルスポンサー
- [亚洲云 - 高防服务器|服务器租用|福州高防|广东电信|香港服务器|美国服务器|海外服务器 - 国内靠谱的企业级云计算服务提供商](https://www.asiayun.com/aff/QQCOOQKZ) (sponsored Chinese API server)
- [找资源 - 阿里云盘资源搜索引擎](https://zhaoziyuan.pw/)
- [JetBrains: Essential tools for software developers and teams](https://www.jetbrains.com/)
## コントリビューター
これらの素晴らしい人々に感謝します:
[![Contributors](http://contributors.nn.ci/api?repo=alist-org/alist&repo=alist-org/alist-web&repo=alist-org/docs)](https://github.com/alist-org/alist/graphs/contributors)
## ライセンス
`AList` は AGPL-3.0 ライセンスの下でライセンスされたオープンソースソフトウェアです。
## 免責事項
- このプログラムはフリーでオープンソースのプロジェクトです。ネットワークディスク上でファイルを共有するように設計されており、golang のダウンロードや学習に便利です。利用にあたっては関連法規を遵守し、悪用しないようお願いします;
- このプログラムは、公式インターフェースの動作を破壊することなく、公式 sdk/インターフェースを呼び出すことで実装されています;
- このプログラムは、302リダイレクト/トラフィック転送のみを行い、いかなるユーザーデータも傍受、保存、改ざんしません;
- このプログラムを使用する前に、アカウントの禁止、ダウンロード速度の制限など、対応するリスクを理解し、負担する必要があります;
- もし侵害があれば、[メール](mailto:i@nn.ci)で私に連絡してください。
---
> [@Blog](https://nn.ci/) · [@GitHub](https://github.com/Xhofe) · [@TelegramGroup](https://t.me/alist_chat) · [@Discord](https://discord.gg/F4ymsH4xv2)

View File

@ -34,6 +34,25 @@ func (d *Pan123) getS3PreSignedUrls(ctx context.Context, upReq *UploadResp, star
return &s3PreSignedUrls, nil return &s3PreSignedUrls, nil
} }
func (d *Pan123) getS3Auth(ctx context.Context, upReq *UploadResp, start, end int) (*S3PreSignedURLs, error) {
data := base.Json{
"StorageNode": upReq.Data.StorageNode,
"bucket": upReq.Data.Bucket,
"key": upReq.Data.Key,
"partNumberEnd": end,
"partNumberStart": start,
"uploadId": upReq.Data.UploadId,
}
var s3PreSignedUrls S3PreSignedURLs
_, err := d.request(S3Auth, http.MethodPost, func(req *resty.Request) {
req.SetBody(data).SetContext(ctx)
}, &s3PreSignedUrls)
if err != nil {
return nil, err
}
return &s3PreSignedUrls, nil
}
func (d *Pan123) completeS3(ctx context.Context, upReq *UploadResp, file model.FileStreamer, isMultipart bool) error { func (d *Pan123) completeS3(ctx context.Context, upReq *UploadResp, file model.FileStreamer, isMultipart bool) error {
data := base.Json{ data := base.Json{
"StorageNode": upReq.Data.StorageNode, "StorageNode": upReq.Data.StorageNode,
@ -51,11 +70,17 @@ func (d *Pan123) completeS3(ctx context.Context, upReq *UploadResp, file model.F
} }
func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.FileStreamer, reader io.Reader, up driver.UpdateProgress) error { func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.FileStreamer, reader io.Reader, up driver.UpdateProgress) error {
chunkSize := int64(1024 * 1024 * 5) chunkSize := int64(1024 * 1024 * 16)
// fetch s3 pre signed urls // fetch s3 pre signed urls
chunkCount := int(math.Ceil(float64(file.GetSize()) / float64(chunkSize))) chunkCount := int(math.Ceil(float64(file.GetSize()) / float64(chunkSize)))
// upload 10 chunks each batch // only 1 batch is allowed
batchSize := 10 isMultipart := chunkCount > 1
batchSize := 1
getS3UploadUrl := d.getS3Auth
if isMultipart {
batchSize = 10
getS3UploadUrl = d.getS3PreSignedUrls
}
for i := 1; i <= chunkCount; i += batchSize { for i := 1; i <= chunkCount; i += batchSize {
if utils.IsCanceled(ctx) { if utils.IsCanceled(ctx) {
return ctx.Err() return ctx.Err()
@ -65,7 +90,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
if end > chunkCount+1 { if end > chunkCount+1 {
end = chunkCount + 1 end = chunkCount + 1
} }
s3PreSignedUrls, err := d.getS3PreSignedUrls(ctx, upReq, start, end) s3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, start, end)
if err != nil { if err != nil {
return err return err
} }
@ -78,7 +103,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
if j == chunkCount { if j == chunkCount {
curSize = file.GetSize() - (int64(chunkCount)-1)*chunkSize curSize = file.GetSize() - (int64(chunkCount)-1)*chunkSize
} }
err = d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, j, end, io.LimitReader(reader, chunkSize), curSize, false) err = d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, j, end, io.LimitReader(reader, chunkSize), curSize, false, getS3UploadUrl)
if err != nil { if err != nil {
return err return err
} }
@ -89,7 +114,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
return d.completeS3(ctx, upReq, file, chunkCount > 1) return d.completeS3(ctx, upReq, file, chunkCount > 1)
} }
func (d *Pan123) uploadS3Chunk(ctx context.Context, upReq *UploadResp, s3PreSignedUrls *S3PreSignedURLs, cur, end int, reader io.Reader, curSize int64, retry bool) error { func (d *Pan123) uploadS3Chunk(ctx context.Context, upReq *UploadResp, s3PreSignedUrls *S3PreSignedURLs, cur, end int, reader io.Reader, curSize int64, retry bool, getS3UploadUrl func(ctx context.Context, upReq *UploadResp, start int, end int) (*S3PreSignedURLs, error)) error {
uploadUrl := s3PreSignedUrls.Data.PreSignedUrls[strconv.Itoa(cur)] uploadUrl := s3PreSignedUrls.Data.PreSignedUrls[strconv.Itoa(cur)]
if uploadUrl == "" { if uploadUrl == "" {
return fmt.Errorf("upload url is empty, s3PreSignedUrls: %+v", s3PreSignedUrls) return fmt.Errorf("upload url is empty, s3PreSignedUrls: %+v", s3PreSignedUrls)
@ -111,13 +136,13 @@ func (d *Pan123) uploadS3Chunk(ctx context.Context, upReq *UploadResp, s3PreSign
return fmt.Errorf("upload s3 chunk %d failed, status code: %d", cur, res.StatusCode) return fmt.Errorf("upload s3 chunk %d failed, status code: %d", cur, res.StatusCode)
} }
// refresh s3 pre signed urls // refresh s3 pre signed urls
newS3PreSignedUrls, err := d.getS3PreSignedUrls(ctx, upReq, cur, end) newS3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, cur, end)
if err != nil { if err != nil {
return err return err
} }
s3PreSignedUrls.Data.PreSignedUrls = newS3PreSignedUrls.Data.PreSignedUrls s3PreSignedUrls.Data.PreSignedUrls = newS3PreSignedUrls.Data.PreSignedUrls
// retry // retry
return d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, cur, end, reader, curSize, true) return d.uploadS3Chunk(ctx, upReq, s3PreSignedUrls, cur, end, reader, curSize, true, getS3UploadUrl)
} }
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)

View File

@ -1,10 +1,14 @@
package _123 package _123
import ( import (
"crypto/md5"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time"
"github.com/alist-org/alist/v3/drivers/base" "github.com/alist-org/alist/v3/drivers/base"
"github.com/alist-org/alist/v3/pkg/utils" "github.com/alist-org/alist/v3/pkg/utils"
@ -17,7 +21,7 @@ import (
const ( const (
AApi = "https://www.123pan.com/a/api" AApi = "https://www.123pan.com/a/api"
BApi = "https://www.123pan.com/b/api" BApi = "https://www.123pan.com/b/api"
MainApi = AApi MainApi = BApi
SignIn = MainApi + "/user/sign_in" SignIn = MainApi + "/user/sign_in"
Logout = MainApi + "/user/logout" Logout = MainApi + "/user/logout"
UserInfo = MainApi + "/user/info" UserInfo = MainApi + "/user/info"
@ -33,6 +37,7 @@ const (
S3Auth = MainApi + "/file/s3_upload_object/auth" S3Auth = MainApi + "/file/s3_upload_object/auth"
UploadCompleteV2 = MainApi + "/file/upload_complete/v2" UploadCompleteV2 = MainApi + "/file/upload_complete/v2"
S3Complete = MainApi + "/file/s3_complete_multipart_upload" S3Complete = MainApi + "/file/s3_complete_multipart_upload"
AuthKeySalt = "8-8D$sL8gPjom7bk#cY"
) )
func (d *Pan123) login() error { func (d *Pan123) login() error {
@ -70,6 +75,20 @@ func (d *Pan123) login() error {
return err return err
} }
func authKey(reqUrl string) (*string, error) {
reqURL, err := url.Parse(reqUrl)
if err != nil {
return nil, err
}
nowUnix := time.Now().Unix()
random := rand.Intn(0x989680)
p4 := fmt.Sprintf("%d|%d|%s|%s|%s|%s", nowUnix, random, reqURL.Path, "web", "3", AuthKeySalt)
authKey := fmt.Sprintf("%d-%d-%x", nowUnix, random, md5.Sum([]byte(p4)))
return &authKey, nil
}
func (d *Pan123) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { func (d *Pan123) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
req := base.RestyClient.R() req := base.RestyClient.R()
req.SetHeaders(map[string]string{ req.SetHeaders(map[string]string{
@ -86,6 +105,11 @@ func (d *Pan123) request(url string, method string, callback base.ReqCallback, r
if resp != nil { if resp != nil {
req.SetResult(resp) req.SetResult(resp)
} }
authKey, err := authKey(url)
if err != nil {
return nil, err
}
req.SetQueryParam("auth-key", *authKey)
res, err := req.Execute(method, url) res, err := req.Execute(method, url)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -154,13 +154,16 @@ func InitialSettings() []model.SettingItem {
// SSO settings // SSO settings
{Key: conf.SSOLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PUBLIC}, {Key: conf.SSOLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PUBLIC},
{Key: conf.SSOLoginplatform, Type: conf.TypeSelect, Options: "Casdoor,Github,Microsoft,Google,Dingtalk,OIDC", Group: model.SSO, Flag: model.PUBLIC}, {Key: conf.SSOLoginPlatform, Type: conf.TypeSelect, Options: "Casdoor,Github,Microsoft,Google,Dingtalk,OIDC", Group: model.SSO, Flag: model.PUBLIC},
{Key: conf.SSOClientId, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOClientId, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOClientSecret, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOClientSecret, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOOrganizationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOOrganizationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOApplicationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOApplicationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOEndpointName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOEndpointName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOJwtPublicKey, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOJwtPublicKey, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSOAutoRegister, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSODefaultDir, Value: "/", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
{Key: conf.SSODefaultPermission, Value: "0", Type: conf.TypeNumber, Group: model.SSO, Flag: model.PRIVATE},
// qbittorrent settings // qbittorrent settings
{Key: conf.QbittorrentUrl, Value: "http://admin:adminadmin@localhost:8080/", Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.QbittorrentUrl, Value: "http://admin:adminadmin@localhost:8080/", Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},

View File

@ -57,14 +57,17 @@ const (
IndexProgress = "index_progress" IndexProgress = "index_progress"
//SSO //SSO
SSOClientId = "sso_client_id" SSOClientId = "sso_client_id"
SSOClientSecret = "sso_client_secret" SSOClientSecret = "sso_client_secret"
SSOLoginEnabled = "sso_login_enabled" SSOLoginEnabled = "sso_login_enabled"
SSOLoginplatform = "sso_login_platform" SSOLoginPlatform = "sso_login_platform"
SSOOrganizationName = "sso_organization_name" SSOOrganizationName = "sso_organization_name"
SSOApplicationName = "sso_application_name" SSOApplicationName = "sso_application_name"
SSOEndpointName = "sso_endpoint_name" SSOEndpointName = "sso_endpoint_name"
SSOJwtPublicKey = "sso_jwt_public_key" SSOJwtPublicKey = "sso_jwt_public_key"
SSOAutoRegister = "sso_auto_register"
SSODefaultDir = "sso_default_dir"
SSODefaultPermission = "sso_default_permission"
// qbittorrent // qbittorrent
QbittorrentUrl = "qbittorrent_url" QbittorrentUrl = "qbittorrent_url"

View File

@ -33,7 +33,7 @@ type User struct {
// 10: can add qbittorrent tasks // 10: can add qbittorrent tasks
Permission int32 `json:"permission"` Permission int32 `json:"permission"`
OtpSecret string `json:"-"` OtpSecret string `json:"-"`
SsoID string `json:"sso_id"` SsoID string `json:"sso_id"` // unique by sso platform
} }
func (u User) IsGuest() bool { func (u User) IsGuest() bool {

View File

@ -11,8 +11,10 @@ import (
"github.com/alist-org/alist/v3/internal/conf" "github.com/alist-org/alist/v3/internal/conf"
"github.com/alist-org/alist/v3/internal/db" "github.com/alist-org/alist/v3/internal/db"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/internal/setting" "github.com/alist-org/alist/v3/internal/setting"
"github.com/alist-org/alist/v3/pkg/utils" "github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/pkg/utils/random"
"github.com/alist-org/alist/v3/server/common" "github.com/alist-org/alist/v3/server/common"
"github.com/coreos/go-oidc" "github.com/coreos/go-oidc"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -20,14 +22,15 @@ import (
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"gorm.io/gorm"
) )
var opts = totp.ValidateOpts{ var opts = totp.ValidateOpts{
// state verify won't expire in 30 secs, which is quite enough for the callback // state verify won't expire in 30 secs, which is quite enough for the callback
Period: 30, Period: 30,
Skew: 1, Skew: 1,
// in some OIDC providers(such as Authelia), state parameter must be at least 8 characters // in some OIDC providers(such as Authelia), state parameter must be at least 8 characters
Digits: otp.DigitsEight, Digits: otp.DigitsEight,
Algorithm: otp.AlgorithmSHA1, Algorithm: otp.AlgorithmSHA1,
} }
@ -35,7 +38,7 @@ func SSOLoginRedirect(c *gin.Context) {
method := c.Query("method") method := c.Query("method")
enabled := setting.GetBool(conf.SSOLoginEnabled) enabled := setting.GetBool(conf.SSOLoginEnabled)
clientId := setting.GetStr(conf.SSOClientId) clientId := setting.GetStr(conf.SSOClientId)
platform := setting.GetStr(conf.SSOLoginplatform) platform := setting.GetStr(conf.SSOLoginPlatform)
var r_url string var r_url string
var redirect_uri string var redirect_uri string
if enabled { if enabled {
@ -76,7 +79,7 @@ func SSOLoginRedirect(c *gin.Context) {
return return
} }
// generate state parameter // generate state parameter
state,err := totp.GenerateCodeCustom(base32.StdEncoding.EncodeToString([]byte(oauth2Config.ClientSecret)), time.Now(), opts) state, err := totp.GenerateCodeCustom(base32.StdEncoding.EncodeToString([]byte(oauth2Config.ClientSecret)), time.Now(), opts)
if err != nil { if err != nil {
common.ErrorStrResp(c, err.Error(), 400) common.ErrorStrResp(c, err.Error(), 400)
return return
@ -118,13 +121,39 @@ func GetOIDCClient(c *gin.Context) (*oauth2.Config, error) {
}, nil }, nil
} }
func autoRegister(username, userID string, err error) (*model.User, error) {
if !errors.Is(err, gorm.ErrRecordNotFound) || !setting.GetBool(conf.SSOAutoRegister) {
return nil, err
}
if username == "" {
return nil, errors.New("cannot get username from SSO provider")
}
user := &model.User{
ID: 0,
Username: username,
Password: random.String(16),
Permission: int32(setting.GetInt(conf.SSODefaultPermission, 0)),
BasePath: setting.GetStr(conf.SSODefaultDir),
Role: 0,
Disabled: false,
SsoID: userID,
}
if err = db.CreateUser(user); err != nil {
if strings.HasPrefix(err.Error(), "UNIQUE constraint failed") && strings.HasSuffix(err.Error(), "username") {
user.Username = user.Username + "_" + userID
if err = db.CreateUser(user); err != nil {
return nil, err
}
} else {
return nil, err
}
}
return user, nil
}
func OIDCLoginCallback(c *gin.Context) { func OIDCLoginCallback(c *gin.Context) {
argument := c.Query("method") argument := c.Query("method")
enabled := setting.GetBool(conf.SSOLoginEnabled)
clientId := setting.GetStr(conf.SSOClientId) clientId := setting.GetStr(conf.SSOClientId)
if !enabled {
common.ErrorResp(c, errors.New("invalid request"), 500)
}
endpoint := setting.GetStr(conf.SSOEndpointName) endpoint := setting.GetStr(conf.SSOEndpointName)
provider, err := oidc.NewProvider(c, endpoint) provider, err := oidc.NewProvider(c, endpoint)
if err != nil { if err != nil {
@ -170,7 +199,7 @@ func OIDCLoginCallback(c *gin.Context) {
} }
claims := UserInfo{} claims := UserInfo{}
if err := idToken.Claims(&claims); err != nil { if err := idToken.Claims(&claims); err != nil {
c.Error(err) common.ErrorResp(c, err, 400)
return return
} }
UserID := claims.Name UserID := claims.Name
@ -189,7 +218,10 @@ func OIDCLoginCallback(c *gin.Context) {
if argument == "sso_get_token" { if argument == "sso_get_token" {
user, err := db.GetUserBySSOID(UserID) user, err := db.GetUserBySSOID(UserID)
if err != nil { if err != nil {
common.ErrorResp(c, err, 400) user, err = autoRegister(UserID, UserID, err)
if err != nil {
common.ErrorResp(c, err, 400)
}
} }
token, err := common.GenerateToken(user.Username) token, err := common.GenerateToken(user.Username)
if err != nil { if err != nil {
@ -209,133 +241,145 @@ func OIDCLoginCallback(c *gin.Context) {
} }
func SSOLoginCallback(c *gin.Context) { func SSOLoginCallback(c *gin.Context) {
enabled := setting.GetBool(conf.SSOLoginEnabled)
if !enabled {
common.ErrorResp(c, errors.New("sso login is disabled"), 500)
}
argument := c.Query("method") argument := c.Query("method")
if argument == "get_sso_id" || argument == "sso_get_token" { if !utils.SliceContains([]string{"get_sso_id", "sso_get_token"}, argument) {
enabled := setting.GetBool(conf.SSOLoginEnabled) common.ErrorResp(c, errors.New("invalid request"), 500)
clientId := setting.GetStr(conf.SSOClientId) }
platform := setting.GetStr(conf.SSOLoginplatform) clientId := setting.GetStr(conf.SSOClientId)
clientSecret := setting.GetStr(conf.SSOClientSecret) platform := setting.GetStr(conf.SSOLoginPlatform)
var url1, url2, additionalbody, scope, authstring, idstring string clientSecret := setting.GetStr(conf.SSOClientSecret)
switch platform { var tokenUrl, userUrl, scope, authField, idField, usernameField string
case "Github": additionalForm := make(map[string]string)
url1 = "https://github.com/login/oauth/access_token" switch platform {
url2 = "https://api.github.com/user" case "Github":
additionalbody = "" tokenUrl = "https://github.com/login/oauth/access_token"
authstring = "code" userUrl = "https://api.github.com/user"
scope = "read:user" authField = "code"
idstring = "id" scope = "read:user"
case "Microsoft": idField = "id"
url1 = "https://login.microsoftonline.com/common/oauth2/v2.0/token" usernameField = "login"
url2 = "https://graph.microsoft.com/v1.0/me" case "Microsoft":
additionalbody = "&grant_type=authorization_code" tokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
scope = "user.read" userUrl = "https://graph.microsoft.com/v1.0/me"
authstring = "code" additionalForm["grant_type"] = "authorization_code"
idstring = "id" scope = "user.read"
case "Google": authField = "code"
url1 = "https://oauth2.googleapis.com/token" idField = "id"
url2 = "https://www.googleapis.com/oauth2/v1/userinfo" usernameField = "displayName"
additionalbody = "&grant_type=authorization_code" case "Google":
scope = "https://www.googleapis.com/auth/userinfo.profile" tokenUrl = "https://oauth2.googleapis.com/token"
authstring = "code" userUrl = "https://www.googleapis.com/oauth2/v1/userinfo"
idstring = "id" additionalForm["grant_type"] = "authorization_code"
case "Dingtalk": scope = "https://www.googleapis.com/auth/userinfo.profile"
url1 = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" authField = "code"
url2 = "https://api.dingtalk.com/v1.0/contact/users/me" idField = "id"
authstring = "authCode" usernameField = "name"
idstring = "unionId" case "Dingtalk":
case "Casdoor": tokenUrl = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
endpoint := strings.TrimSuffix(setting.GetStr(conf.SSOEndpointName), "/") userUrl = "https://api.dingtalk.com/v1.0/contact/users/me"
url1 = endpoint + "/api/login/oauth/access_token" authField = "authCode"
url2 = endpoint + "/api/userinfo" idField = "unionId"
additionalbody = "&grant_type=authorization_code" usernameField = "nick"
scope = "profile" case "Casdoor":
authstring = "code" endpoint := strings.TrimSuffix(setting.GetStr(conf.SSOEndpointName), "/")
idstring = "preferred_username" tokenUrl = endpoint + "/api/login/oauth/access_token"
case "OIDC": userUrl = endpoint + "/api/userinfo"
OIDCLoginCallback(c) additionalForm["grant_type"] = "authorization_code"
return scope = "profile"
default: authField = "code"
common.ErrorStrResp(c, "invalid platform", 400) idField = "sub"
return usernameField = "preferred_username"
} case "OIDC":
if enabled { OIDCLoginCallback(c)
callbackCode := c.Query(authstring) return
if callbackCode == "" { default:
common.ErrorStrResp(c, "No code provided", 400) common.ErrorStrResp(c, "invalid platform", 400)
return return
} }
var resp *resty.Response callbackCode := c.Query(authField)
var err error if callbackCode == "" {
if platform == "Dingtalk" { common.ErrorStrResp(c, "No code provided", 400)
resp, err = ssoClient.R().SetHeader("content-type", "application/json").SetHeader("Accept", "application/json"). return
SetBody(map[string]string{ }
"clientId": clientId, var resp *resty.Response
"clientSecret": clientSecret, var err error
"code": callbackCode, if platform == "Dingtalk" {
"grantType": "authorization_code", resp, err = ssoClient.R().SetHeader("content-type", "application/json").SetHeader("Accept", "application/json").
}). SetBody(map[string]string{
Post(url1) "clientId": clientId,
} else { "clientSecret": clientSecret,
resp, err = ssoClient.R().SetHeader("content-type", "application/x-www-form-urlencoded").SetHeader("Accept", "application/json"). "code": callbackCode,
SetBody("client_id=" + clientId + "&client_secret=" + clientSecret + "&code=" + callbackCode + "&redirect_uri=" + common.GetApiUrl(c.Request) + "/api/auth/sso_callback?method=" + argument + "&scope=" + scope + additionalbody). "grantType": "authorization_code",
Post(url1) }).
} Post(tokenUrl)
if err != nil { } else {
common.ErrorResp(c, err, 400) resp, err = ssoClient.R().SetHeader("Accept", "application/json").
return SetFormData(map[string]string{
} "client_id": clientId,
if platform == "Dingtalk" { "client_secret": clientSecret,
accessToken := utils.Json.Get(resp.Body(), "accessToken").ToString() "code": callbackCode,
resp, err = ssoClient.R().SetHeader("x-acs-dingtalk-access-token", accessToken). "redirect_uri": common.GetApiUrl(c.Request) + "/api/auth/sso_callback?method=" + argument,
Get(url2) "scope": scope,
} else { }).SetFormData(additionalForm).Post(tokenUrl)
accessToken := utils.Json.Get(resp.Body(), "access_token").ToString() }
resp, err = ssoClient.R().SetHeader("Authorization", "Bearer "+accessToken). if err != nil {
Get(url2) common.ErrorResp(c, err, 400)
} return
if err != nil { }
common.ErrorResp(c, err, 400) if platform == "Dingtalk" {
return accessToken := utils.Json.Get(resp.Body(), "accessToken").ToString()
} resp, err = ssoClient.R().SetHeader("x-acs-dingtalk-access-token", accessToken).
UserID := utils.Json.Get(resp.Body(), idstring).ToString() Get(userUrl)
if UserID == "0" { } else {
common.ErrorResp(c, errors.New("error occured"), 400) accessToken := utils.Json.Get(resp.Body(), "access_token").ToString()
return resp, err = ssoClient.R().SetHeader("Authorization", "Bearer "+accessToken).
} Get(userUrl)
if argument == "get_sso_id" { }
html := fmt.Sprintf(`<!DOCTYPE html> if err != nil {
common.ErrorResp(c, err, 400)
return
}
userID := utils.Json.Get(resp.Body(), idField).ToString()
if utils.SliceContains([]string{"", "0"}, userID) {
common.ErrorResp(c, errors.New("error occured"), 400)
return
}
if argument == "get_sso_id" {
html := fmt.Sprintf(`<!DOCTYPE html>
<head></head> <head></head>
<body> <body>
<script> <script>
window.opener.postMessage({"sso_id": "%s"}, "*") window.opener.postMessage({"sso_id": "%s"}, "*")
window.close() window.close()
</script> </script>
</body>`, UserID) </body>`, userID)
c.Data(200, "text/html; charset=utf-8", []byte(html)) c.Data(200, "text/html; charset=utf-8", []byte(html))
return return
} }
if argument == "sso_get_token" { username := utils.Json.Get(resp.Body(), usernameField).ToString()
user, err := db.GetUserBySSOID(UserID) user, err := db.GetUserBySSOID(userID)
if err != nil { if err != nil {
common.ErrorResp(c, err, 400) user, err = autoRegister(username, userID, err)
} if err != nil {
token, err := common.GenerateToken(user.Username) common.ErrorResp(c, err, 400)
if err != nil { return
common.ErrorResp(c, err, 400)
}
html := fmt.Sprintf(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"token":"%s"}, "*")
window.close()
</script>
</body>`, token)
c.Data(200, "text/html; charset=utf-8", []byte(html))
return
}
} else {
common.ErrorResp(c, errors.New("invalid request"), 500)
} }
} }
token, err := common.GenerateToken(user.Username)
if err != nil {
common.ErrorResp(c, err, 400)
}
html := fmt.Sprintf(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"token":"%s"}, "*")
window.close()
</script>
</body>`, token)
c.Data(200, "text/html; charset=utf-8", []byte(html))
} }