diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5bd7cfb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Ignore Docker Compose configuration files +docker-compose.yaml + +# Ignore development Dockerfile +Dockerfile.dev + +# Ignore the data directory +data/ + +# Ignore version control system directories +.git/ + +# Ignore log and temporary files +*.log +*.tmp +*.swp + +# Ignore editor/IDE configuration files +.vscode/ +.idea/ + +# Ignore binaries and build cache +release/ +bin/ +*.exe +*.out \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..d0e517b --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,72 @@ +# Use build arguments for Go version and architecture +ARG GO_VERSION=1.22 +ARG BUILDARCH=amd64 + +# Stage 1: Builder Stage +# FROM golang:${GO_VERSION}-alpine AS builder +FROM crazymax/xgo:${GO_VERSION} AS builder + +# Set up working directory +WORKDIR /app + +# Step 1: Copy the source code +COPY . . + +# Step 2: Download dependencies +RUN go mod tidy && go mod download + + +# Step 3: Install swag and Run the build script +RUN go install github.com/swaggo/swag/cmd/swag@latest && \ + swag init -g cmd/apimain.go --output docs/api --instanceName api --exclude http/controller/admin && \ + swag init -g cmd/apimain.go --output docs/admin --instanceName admin --exclude http/controller/api + +# Build the Go application with CGO enabled and specified ldflags +RUN CGO_ENABLED=1 GOOS=linux go build -a \ + -ldflags "-s -w --extldflags '-static -fpic'" \ + -installsuffix cgo -o release/apimain cmd/apimain.go + +# Stage 2: Frontend Build Stage (builder2) +FROM node:18-alpine AS builder2 + +# Set working directory +WORKDIR /frontend + +RUN apk update && apk add git --no-cache + +# Clone the frontend repository +RUN git clone https://github.com/lejianwen/rustdesk-api-web . + +# Install npm dependencies and build the frontend +RUN npm install && npm run build + +# Stage 2: Final Image +FROM alpine:latest + +# Set up working directory +WORKDIR /app + +# Install necessary runtime dependencies +RUN apk add --no-cache tzdata file + +# Copy the built application and resources from the builder stage +COPY --from=builder /app/release /app/ +COPY --from=builder /app/conf /app/conf/ +COPY --from=builder /app/resources /app/resources/ +COPY --from=builder /app/docs /app/docs/ +# Copy frontend build from builder2 stage +COPY --from=builder2 /frontend/dist/ /app/resources/admin/ + +# Ensure the binary is correctly built and linked +RUN file /app/apimain && \ + mkdir -p /app/data && \ + mkdir -p /app/runtime + +# Set up a volume for persistent data +VOLUME /app/data + +# Expose the necessary port +EXPOSE 21114 + +# Define the command to run the application +CMD ["./apimain"] \ No newline at end of file diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 895c3b1..a4474c7 --- a/build.sh +++ b/build.sh @@ -1,16 +1,46 @@ #!/bin/sh -rm release -rf +set -e +# Automatically get the current environment's GOARCH; if not defined, use the detected system architecture +GOARCH=${GOARCH:-$(go env GOARCH)} +DOCS="true" +# Safely remove the old release directory +rm -rf release + +# Set Go environment variables go env -w GO111MODULE=on go env -w GOPROXY=https://goproxy.cn,direct go env -w CGO_ENABLED=1 go env -w GOOS=linux -go env -w GOARCH=amd64 -swag init -g cmd/apimain.go --output docs/api --instanceName api --exclude http/controller/admin -swag init -g cmd/apimain.go --output docs/admin --instanceName admin --exclude http/controller/api +go env -w GOARCH=${GOARCH} + + +# Generate Swagger documentation if DOCS is not empty +if [ -n "${DOCS}" ]; then + # Check if swag is installed + if ! command -v swag &> /dev/null; then + echo "swag command not found. Please install it using:" + echo "go install github.com/swaggo/swag/cmd/swag@latest" + echo "Skipping Swagger documentation generation due to missing swag tool." + else + echo "Generating Swagger documentation..." + swag init -g cmd/apimain.go --output docs/api --instanceName api --exclude http/controller/admin + swag init -g cmd/apimain.go --output docs/admin --instanceName admin --exclude http/controller/api + fi +else + echo "Skipping Swagger documentation generation due to DOCS is empty." +fi + +# Compile the Go code and output it to the release directory go build -o release/apimain cmd/apimain.go + +# Copy resource files to the release directory cp -ar resources release/ cp -ar docs release/ cp -ar conf release/ + +# Create necessary directory structures mkdir -p release/data mkdir -p release/runtime + +echo "Build and setup completed successfully." \ No newline at end of file diff --git a/config/oauth.go b/config/oauth.go index 81108aa..ff9272f 100644 --- a/config/oauth.go +++ b/config/oauth.go @@ -11,3 +11,10 @@ type GoogleOauth struct { ClientSecret string `mapstructure:"client-secret"` RedirectUrl string `mapstructure:"redirect-url"` } + +type OidcOauth struct { + Issuer string `mapstructure:"issuer"` + ClientId string `mapstructure:"client-id"` + ClientSecret string `mapstructure:"client-secret"` + RedirectUrl string `mapstructure:"redirect-url"` +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index bd95588..d015bc1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,15 +1,20 @@ services: rustdesk-api: - image: lejianwen/rustdesk-api + build: + context: . + dockerfile: Dockerfile.dev + # image: lejianwen/rustdesk-api container_name: rustdesk-api environment: - TZ=Asia/Shanghai - RUSTDESK_API_RUSTDESK_ID_SERVER=192.168.1.66:21116 - RUSTDESK_API_RUSTDESK_RELAY_SERVER=192.168.1.66:21117 - - RUSTDESK_API_RUSTDESK_API_SERVER=http://192.168.1.66:21114 + - RUSTDESK_API_RUSTDESK_API_SERVER=http://127.0.0.1:21114 - RUSTDESK_API_RUSTDESK_KEY=123456789 ports: - 21114:21114 volumes: - - /data/rustdesk/api:/app/data #将数据库挂载出来方便备份 + - ./data/rustdesk/api:/app/data #将数据库挂载出来方便备份 + - ./conf:/app/conf # config + # - ./resources:/app/resources # 静态资源 restart: unless-stopped \ No newline at end of file diff --git a/http/controller/admin/oauth.go b/http/controller/admin/oauth.go index a3571f2..fec011d 100644 --- a/http/controller/admin/oauth.go +++ b/http/controller/admin/oauth.go @@ -140,6 +140,13 @@ func (o *Oauth) Unbind(c *gin.Context) { return } } + if f.Op == model.OauthTypeOidc { + err = service.AllService.OauthService.UnBindOidcUser(u.Id) + if err != nil { + response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+err.Error()) + return + } + } response.Success(c, nil) } diff --git a/http/controller/api/login.go b/http/controller/api/login.go index 57eeb48..bae13e1 100644 --- a/http/controller/api/login.go +++ b/http/controller/api/login.go @@ -92,6 +92,10 @@ func (l *Login) LoginOptions(c *gin.Context) { if err == nil { oauthOks = append(oauthOks, model.OauthTypeGoogle) } + err, _ = service.AllService.OauthService.GetOauthConfig(model.OauthTypeOidc) + if err == nil { + oauthOks = append(oauthOks, model.OauthTypeOidc) + } oauthOks = append(oauthOks, model.OauthTypeWebauth) var oidcItems []map[string]string for _, v := range oauthOks { diff --git a/http/controller/api/ouath.go b/http/controller/api/ouath.go index fc37322..47fa129 100644 --- a/http/controller/api/ouath.go +++ b/http/controller/api/ouath.go @@ -32,7 +32,7 @@ func (o *Oauth) OidcAuth(c *gin.Context) { response.Error(c, response.TranslateMsg(c, "ParamsError")+err.Error()) return } - if f.Op != model.OauthTypeWebauth && f.Op != model.OauthTypeGoogle && f.Op != model.OauthTypeGithub { + if f.Op != model.OauthTypeWebauth && f.Op != model.OauthTypeGoogle && f.Op != model.OauthTypeGithub && f.Op != model.OauthTypeOidc { response.Error(c, response.TranslateMsg(c, "ParamsError")) return } @@ -254,6 +254,70 @@ func (o *Oauth) OauthCallback(c *gin.Context) { return } } + if ty == model.OauthTypeOidc { + code := c.Query("code") + err, userData := service.AllService.OauthService.OidcCallback(code) + if err != nil { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "OauthFailed")+response.TranslateMsg(c, err.Error())) + return + } + //将空格替换成_ + // OidcName := strings.Replace(userData.Name, " ", "_", -1) + if ac == service.OauthActionTypeBind { + //fmt.Println("bind", ty, userData) + utr := service.AllService.OauthService.UserThirdInfo(ty, userData.Sub) + if utr.UserId > 0 { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "OauthHasBindOtherUser")) + return + } + //绑定 + u := service.AllService.UserService.InfoById(v.UserId) + if u == nil { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "ItemNotFound")) + return + } + //绑定, user preffered_username as username + err = service.AllService.OauthService.BindOidcUser(userData.Sub, userData.PreferredUsername, v.UserId) + if err != nil { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "BindFail")) + return + } + c.String(http.StatusOK, response.TranslateMsg(c, "BindSuccess")) + return + } else if ac == service.OauthActionTypeLogin { + if v.UserId != 0 { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "OauthHasBeenSuccess")) + return + } + u := service.AllService.UserService.InfoByOidcSub(userData.Sub) + if u == nil { + oa := service.AllService.OauthService.InfoByOp(ty) + if !*oa.AutoRegister { + //c.String(http.StatusInternalServerError, "还未绑定用户,请先绑定") + + v.ThirdName = userData.PreferredUsername + v.ThirdOpenId = userData.Sub + v.ThirdEmail = userData.Email + url := global.Config.Rustdesk.ApiServer + "/_admin/#/oauth/bind/" + cacheKey + c.Redirect(http.StatusFound, url) + return + } + + //自动注册 + u = service.AllService.UserService.RegisterByOidc(userData.PreferredUsername, userData.Sub) + if u.Id == 0 { + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "OauthRegisterFailed")) + return + } + } + + v.UserId = u.Id + service.AllService.OauthService.SetOauthCache(cacheKey, v, 0) + c.String(http.StatusOK, response.TranslateMsg(c, "OauthSuccess")) + return + } + } + c.String(http.StatusInternalServerError, response.TranslateMsg(c, "SystemError")) } diff --git a/http/request/admin/oauth.go b/http/request/admin/oauth.go index 11698ee..db519f8 100644 --- a/http/request/admin/oauth.go +++ b/http/request/admin/oauth.go @@ -15,6 +15,8 @@ type UnBindOauthForm struct { type OauthForm struct { Id uint `json:"id"` Op string `json:"op" validate:"required"` + Issuer string `json:"issuer" validate:"omitempty,url"` + Scopes string `json:"scopes" validate:"omitempty"` ClientId string `json:"client_id" validate:"required"` ClientSecret string `json:"client_secret" validate:"required"` RedirectUrl string `json:"redirect_url" validate:"required"` @@ -28,6 +30,8 @@ func (of *OauthForm) ToOauth() *model.Oauth { ClientSecret: of.ClientSecret, RedirectUrl: of.RedirectUrl, AutoRegister: of.AutoRegister, + Issuer: of.Issuer, + Scopes: of.Scopes, } oa.Id = of.Id return oa diff --git a/model/oauth.go b/model/oauth.go index 64de80c..35e7b96 100644 --- a/model/oauth.go +++ b/model/oauth.go @@ -7,12 +7,15 @@ type Oauth struct { ClientSecret string `json:"client_secret"` RedirectUrl string `json:"redirect_url"` AutoRegister *bool `json:"auto_register"` + Scopes string `json:"scopes"` + Issuer string `json:"issuer"` TimeModel } const ( OauthTypeGithub = "github" OauthTypeGoogle = "google" + OauthTypeOidc = "oidc" OauthTypeWebauth = "webauth" ) diff --git a/service/oauth.go b/service/oauth.go index 0ee6add..ea97608 100644 --- a/service/oauth.go +++ b/service/oauth.go @@ -17,8 +17,17 @@ import ( "strconv" "sync" "time" + "strings" ) +// Define a struct to parse the .well-known/openid-configuration response +type OidcEndpoint struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + UserInfo string `json:"userinfo_endpoint"` +} + type OauthService struct { } @@ -78,6 +87,14 @@ type GoogleUserdata struct { Picture string `json:"picture"` VerifiedEmail bool `json:"verified_email"` } +type OidcUserdata struct { + Sub string `json:"sub"` + Email string `json:"email"` + VerifiedEmail bool `json:"email_verified"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` +} + type OauthCacheItem struct { UserId uint `json:"user_id"` Id string `json:"id"` //rustdesk的设备ID @@ -137,35 +154,102 @@ func (os *OauthService) BeginAuth(op string) (error error, code, url string) { return err, code, "" } -// GetOauthConfig 获取配置 +// Method to fetch OIDC configuration dynamically +func FetchOidcConfig(issuer string) (error, OidcEndpoint) { + configURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + + // Get the HTTP client (with or without proxy based on configuration) + client := getHTTPClientWithProxy() + + resp, err := client.Get(configURL) + if err != nil { + return errors.New("failed to fetch OIDC configuration"), OidcEndpoint{} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("OIDC configuration not found, status code: %d"), OidcEndpoint{} + } + + var endpoint OidcEndpoint + if err := json.NewDecoder(resp.Body).Decode(&endpoint); err != nil { + return errors.New("failed to parse OIDC configuration"), OidcEndpoint{} + } + + return nil, endpoint +} + +// GetOauthConfig retrieves the OAuth2 configuration based on the provider type func (os *OauthService) GetOauthConfig(op string) (error, *oauth2.Config) { - if op == model.OauthTypeGithub { - g := os.InfoByOp(model.OauthTypeGithub) - if g.Id == 0 || g.ClientId == "" || g.ClientSecret == "" || g.RedirectUrl == "" { - return errors.New("ConfigNotFound"), nil - } - return nil, &oauth2.Config{ - ClientID: g.ClientId, - ClientSecret: g.ClientSecret, - RedirectURL: g.RedirectUrl, - Endpoint: github.Endpoint, - Scopes: []string{"read:user", "user:email"}, - } + switch op { + case model.OauthTypeGithub: + return os.getGithubConfig() + case model.OauthTypeGoogle: + return os.getGoogleConfig() + case model.OauthTypeOidc: + return os.getOidcConfig() + default: + return errors.New("unsupported OAuth type"), nil } - if op == model.OauthTypeGoogle { - g := os.InfoByOp(model.OauthTypeGoogle) - if g.Id == 0 || g.ClientId == "" || g.ClientSecret == "" || g.RedirectUrl == "" { - return errors.New("ConfigNotFound"), nil - } - return nil, &oauth2.Config{ - ClientID: g.ClientId, - ClientSecret: g.ClientSecret, - RedirectURL: g.RedirectUrl, - Endpoint: google.Endpoint, - Scopes: []string{"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"}, - } +} + +// Helper function to get GitHub OAuth2 configuration +func (os *OauthService) getGithubConfig() (error, *oauth2.Config) { + g := os.InfoByOp(model.OauthTypeGithub) + if g.Id == 0 || g.ClientId == "" || g.ClientSecret == "" || g.RedirectUrl == "" { + return errors.New("ConfigNotFound"), nil + } + return nil, &oauth2.Config{ + ClientID: g.ClientId, + ClientSecret: g.ClientSecret, + RedirectURL: g.RedirectUrl, + Endpoint: github.Endpoint, + Scopes: []string{"read:user", "user:email"}, + } +} + +// Helper function to get Google OAuth2 configuration +func (os *OauthService) getGoogleConfig() (error, *oauth2.Config) { + g := os.InfoByOp(model.OauthTypeGoogle) + if g.Id == 0 || g.ClientId == "" || g.ClientSecret == "" || g.RedirectUrl == "" { + return errors.New("ConfigNotFound"), nil + } + return nil, &oauth2.Config{ + ClientID: g.ClientId, + ClientSecret: g.ClientSecret, + RedirectURL: g.RedirectUrl, + Endpoint: google.Endpoint, + Scopes: []string{"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"}, + } +} + +// Helper function to get OIDC OAuth2 configuration +func (os *OauthService) getOidcConfig() (error, *oauth2.Config) { + g := os.InfoByOp(model.OauthTypeOidc) + if g.Id == 0 || g.ClientId == "" || g.ClientSecret == "" || g.RedirectUrl == "" || g.Issuer == "" { + return errors.New("ConfigNotFound"), nil + } + + // Set scopes + scopes := strings.TrimSpace(g.Scopes) + if scopes == "" { + scopes = "openid,profile,email" + } + scopeList := strings.Split(scopes, ",") + err, endpoint := FetchOidcConfig(g.Issuer) + if err != nil { + return err, nil + } + return nil, &oauth2.Config{ + ClientID: g.ClientId, + ClientSecret: g.ClientSecret, + RedirectURL: g.RedirectUrl, + Endpoint: oauth2.Endpoint{ + AuthURL: endpoint.AuthURL, + TokenURL: endpoint.TokenURL, + }, + Scopes: scopeList, } - return errors.New("ConfigNotFound"), nil } func getHTTPClientWithProxy() *http.Client { @@ -269,6 +353,53 @@ func (os *OauthService) GoogleCallback(code string) (error error, userData *Goog return } +func (os *OauthService) OidcCallback(code string) (error error, userData *OidcUserdata) { + err, oauthConfig := os.GetOauthConfig(model.OauthTypeOidc) + if err != nil { + return err, nil + } + // 使用代理配置创建 HTTP 客户端 + httpClient := getHTTPClientWithProxy() + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + token, err := oauthConfig.Exchange(ctx, code) + if err != nil { + global.Logger.Warn("oauthConfig.Exchange() failed: ", err) + error = errors.New("GetOauthTokenError") + return + } + + // 使用带有代理的 HTTP 客户端获取用户信息 + client := oauthConfig.Client(ctx, token) + g := os.InfoByOp(model.OauthTypeOidc) + err, endpoint := FetchOidcConfig(g.Issuer) + if err != nil { + global.Logger.Warn("failed fetching OIDC configuration: ", err) + error = errors.New("FetchOidcConfigError") + return + } + resp, err := client.Get(endpoint.UserInfo) + if err != nil { + global.Logger.Warn("failed getting user info: ", err) + error = errors.New("GetOauthUserInfoError") + return + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + global.Logger.Warn("failed closing response body: ", err) + } + }(resp.Body) + + // 解析用户信息 + if err = json.NewDecoder(resp.Body).Decode(&userData); err != nil { + global.Logger.Warn("failed decoding user info: ", err) + error = errors.New("DecodeOauthUserInfoError") + return + } + return +} + func (os *OauthService) UserThirdInfo(op, openid string) *model.UserThird { ut := &model.UserThird{} global.DB.Where("open_id = ? and third_type = ?", openid, op).First(ut) @@ -282,6 +413,11 @@ func (os *OauthService) BindGithubUser(openid, username string, userId uint) err func (os *OauthService) BindGoogleUser(email, username string, userId uint) error { return os.BindOauthUser(model.OauthTypeGoogle, email, username, userId) } + +func (os *OauthService) BindOidcUser(sub, username string, userId uint) error { + return os.BindOauthUser(model.OauthTypeOidc, sub, username, userId) +} + func (os *OauthService) BindOauthUser(thirdType, openid, username string, userId uint) error { utr := &model.UserThird{ OpenId: openid, @@ -298,6 +434,9 @@ func (os *OauthService) UnBindGithubUser(userid uint) error { func (os *OauthService) UnBindGoogleUser(userid uint) error { return os.UnBindThird(model.OauthTypeGoogle, userid) } +func (os *OauthService) UnBindOidcUser(userid uint) error { + return os.UnBindThird(model.OauthTypeOidc, userid) +} func (os *OauthService) UnBindThird(thirdType string, userid uint) error { return global.DB.Where("user_id = ? and third_type = ?", userid, thirdType).Delete(&model.UserThird{}).Error } diff --git a/service/user.go b/service/user.go index fb77ba6..75fb653 100644 --- a/service/user.go +++ b/service/user.go @@ -196,6 +196,11 @@ func (us *UserService) InfoByGoogleEmail(email string) *model.User { return us.InfoByOauthId(model.OauthTypeGithub, email) } +// InfoByOidcSub 根据oidc取用户信息 +func (us *UserService) InfoByOidcSub(sub string) *model.User { + return us.InfoByOauthId(model.OauthTypeOidc, sub) +} + // InfoByOauthId 根据oauth取用户信息 func (us *UserService) InfoByOauthId(thirdType, uid string) *model.User { ut := AllService.OauthService.UserThirdInfo(thirdType, uid) @@ -219,6 +224,11 @@ func (us *UserService) RegisterByGoogle(name string, email string) *model.User { return us.RegisterByOauth(model.OauthTypeGoogle, name, email) } +// RegisterByOidc 注册, use PreferredUsername as username, sub as openid +func (us *UserService) RegisterByOidc(PreferredUsername string, sub string) *model.User { + return us.RegisterByOauth(model.OauthTypeOidc, PreferredUsername, sub) +} + // RegisterByOauth 注册 func (us *UserService) RegisterByOauth(thirdType, thirdName, uid string) *model.User { global.Lock.Lock("registerByOauth")