365 lines
9.5 KiB
Go
365 lines
9.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/labstack/echo-contrib/session"
|
|
"github.com/labstack/echo/v4"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/ngoduykhanh/wireguard-ui/model"
|
|
"github.com/ngoduykhanh/wireguard-ui/store"
|
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
|
)
|
|
|
|
var (
|
|
GitHubOAuth2Config *oauth2.Config
|
|
GitHubAllowedUsers []string
|
|
GitHubAllowedOrgs []string
|
|
GitHubAdminUsers []string
|
|
GitHubUserInfoURL = "https://api.github.com/user"
|
|
GitHubOrgsURL = "https://api.github.com/user/memberships/orgs"
|
|
)
|
|
|
|
var githubLoginRegexp = regexp.MustCompile(`^\w[\w\-.]*$`)
|
|
|
|
var errGitHubUsernameConflict = errors.New("username is already bound to a different GitHub account")
|
|
|
|
func ApplyGitHubAuthConfig(config util.GitHubAuthConfig) error {
|
|
clientSecret := strings.TrimSpace(config.ClientSecret)
|
|
if clientSecret == "" && config.ClientSecretFile != "" {
|
|
secret, err := os.ReadFile(config.ClientSecretFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
clientSecret = strings.TrimSpace(string(secret))
|
|
}
|
|
|
|
GitHubOAuth2Config = &oauth2.Config{
|
|
ClientID: config.ClientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURL: config.RedirectURL,
|
|
Scopes: []string{"read:user", "read:org"},
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: "https://github.com/login/oauth/authorize",
|
|
TokenURL: "https://github.com/login/oauth/access_token",
|
|
},
|
|
}
|
|
GitHubAllowedUsers = append([]string(nil), config.AllowedUsers...)
|
|
GitHubAllowedOrgs = append([]string(nil), config.AllowedOrgs...)
|
|
GitHubAdminUsers = append([]string(nil), config.AdminUsers...)
|
|
return nil
|
|
}
|
|
|
|
type githubUserResponse struct {
|
|
Login string `json:"login"`
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type githubOrgMembership struct {
|
|
Organization struct {
|
|
Login string `json:"login"`
|
|
} `json:"organization"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
func GitHubStart() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
state := generateOAuthState()
|
|
expiresAt := time.Now().UTC().Add(10 * time.Minute).Unix()
|
|
|
|
next := c.QueryParam("next")
|
|
if next == "" {
|
|
next = "/"
|
|
}
|
|
if !isSafeNextURL(next) {
|
|
next = "/"
|
|
}
|
|
|
|
sess, _ := session.Get("session", c)
|
|
sess.Values["oauth_state"] = state
|
|
sess.Values["oauth_state_expires_at"] = expiresAt
|
|
sess.Values["oauth_next"] = next
|
|
sess.Save(c.Request(), c.Response())
|
|
|
|
authURL := GitHubOAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
|
return c.Redirect(http.StatusTemporaryRedirect, authURL)
|
|
}
|
|
}
|
|
|
|
func GitHubCallback(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
sess, _ := session.Get("session", c)
|
|
|
|
state := c.QueryParam("state")
|
|
expectedState, _ := sess.Values["oauth_state"].(string)
|
|
if state == "" || expectedState == "" || state != expectedState {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "invalid oauth state"})
|
|
}
|
|
|
|
expiresAt, _ := sess.Values["oauth_state_expires_at"].(int64)
|
|
if time.Now().UTC().Unix() > expiresAt {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "oauth state expired"})
|
|
}
|
|
|
|
next, _ := sess.Values["oauth_next"].(string)
|
|
if !isSafeNextURL(next) {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "invalid redirect target"})
|
|
}
|
|
|
|
code := c.QueryParam("code")
|
|
if code == "" {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "missing authorization code"})
|
|
}
|
|
|
|
token, err := GitHubOAuth2Config.Exchange(c.Request().Context(), code)
|
|
if err != nil {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "failed to exchange code for token"})
|
|
}
|
|
|
|
githubUser, err := fetchGitHubUser(token.AccessToken)
|
|
if err != nil {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "failed to fetch GitHub user"})
|
|
}
|
|
|
|
githubLogin := strings.ToLower(githubUser.Login)
|
|
if !isValidGitHubLogin(githubLogin) {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "invalid GitHub username"})
|
|
}
|
|
|
|
isAllowed := isGitHubUserAllowed(githubLogin, token.AccessToken)
|
|
if !isAllowed {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "user not authorized"})
|
|
}
|
|
|
|
isAdmin := isGitHubAdmin(githubLogin)
|
|
|
|
user, originalUsername, err := findOrMigrateGitHubUser(db, githubLogin, githubUser.ID, githubUser.Name)
|
|
if err != nil {
|
|
clearOAuthSession(c)
|
|
if errors.Is(err, errGitHubUsernameConflict) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "failed to resolve GitHub user"})
|
|
}
|
|
|
|
user.Username = githubLogin
|
|
user.DisplayName = githubUser.Name
|
|
user.AuthSource = "github"
|
|
user.AuthSubject = fmt.Sprintf("%d", githubUser.ID)
|
|
user.Password = ""
|
|
user.PasswordHash = ""
|
|
user.Admin = isAdmin
|
|
|
|
if originalUsername != "" && originalUsername != githubLogin {
|
|
if err := db.ReplaceUser(originalUsername, *user); err != nil {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "failed to update user"})
|
|
}
|
|
} else {
|
|
if err := db.SaveUser(*user); err != nil {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "failed to save user"})
|
|
}
|
|
}
|
|
|
|
rememberMe := getSessionRememberMe(sess)
|
|
|
|
if err := establishAuthenticatedSession(c, *user, rememberMe); err != nil {
|
|
clearOAuthSession(c)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "failed to establish session"})
|
|
}
|
|
|
|
clearOAuthSession(c)
|
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, next)
|
|
}
|
|
}
|
|
|
|
func generateOAuthState() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return base64.URLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func isSafeNextURL(next string) bool {
|
|
if next == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(next)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if u.Opaque != "" && !strings.HasPrefix(next, "/") {
|
|
return false
|
|
}
|
|
if u.Host != "" {
|
|
return false
|
|
}
|
|
if !strings.HasPrefix(next, "/") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isValidGitHubLogin(login string) bool {
|
|
if login == "" {
|
|
return false
|
|
}
|
|
return githubLoginRegexp.MatchString(login)
|
|
}
|
|
|
|
func isGitHubUserAllowed(login string, accessToken string) bool {
|
|
loginLower := strings.ToLower(login)
|
|
if len(GitHubAllowedUsers) > 0 {
|
|
for _, allowed := range GitHubAllowedUsers {
|
|
if strings.ToLower(allowed) == loginLower {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(GitHubAllowedOrgs) > 0 {
|
|
orgs, err := fetchGitHubOrgs(accessToken)
|
|
if err == nil {
|
|
for _, org := range orgs {
|
|
for _, allowedOrg := range GitHubAllowedOrgs {
|
|
if strings.ToLower(org) == strings.ToLower(allowedOrg) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isGitHubAdmin(login string) bool {
|
|
loginLower := strings.ToLower(login)
|
|
for _, admin := range GitHubAdminUsers {
|
|
if strings.ToLower(admin) == loginLower {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func fetchGitHubUser(accessToken string) (*githubUserResponse, error) {
|
|
req, err := http.NewRequest(http.MethodGet, GitHubUserInfoURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("github api returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var user githubUserResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func fetchGitHubOrgs(accessToken string) ([]string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, GitHubOrgsURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("github api returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var memberships []githubOrgMembership
|
|
if err := json.NewDecoder(resp.Body).Decode(&memberships); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var orgs []string
|
|
for _, m := range memberships {
|
|
if m.State == "active" {
|
|
orgs = append(orgs, strings.ToLower(m.Organization.Login))
|
|
}
|
|
}
|
|
|
|
return orgs, nil
|
|
}
|
|
|
|
func clearOAuthSession(c echo.Context) {
|
|
sess, _ := session.Get("session", c)
|
|
delete(sess.Values, "oauth_state")
|
|
delete(sess.Values, "oauth_state_expires_at")
|
|
delete(sess.Values, "oauth_next")
|
|
sess.Save(c.Request(), c.Response())
|
|
}
|
|
|
|
func getSessionRememberMe(sess *sessions.Session) bool {
|
|
maxAge, ok := sess.Values["max_age"].(int)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return maxAge > 0
|
|
}
|
|
|
|
func findOrMigrateGitHubUser(db store.IStore, githubLogin string, githubID int64, displayName string) (*model.User, string, error) {
|
|
existingUser, err := db.GetUserByAuthIdentity("github", fmt.Sprintf("%d", githubID))
|
|
if err == nil {
|
|
return &existingUser, existingUser.Username, nil
|
|
}
|
|
|
|
localUser, err := db.GetUserByName(githubLogin)
|
|
if err == nil {
|
|
if localUser.AuthSource == "local" && localUser.AuthSubject == "" {
|
|
return &localUser, localUser.Username, nil
|
|
}
|
|
if localUser.AuthSource == "github" && localUser.AuthSubject != "" && localUser.AuthSubject != fmt.Sprintf("%d", githubID) {
|
|
return nil, "", errGitHubUsernameConflict
|
|
}
|
|
return &localUser, localUser.Username, nil
|
|
}
|
|
|
|
newUser := model.User{
|
|
Username: githubLogin,
|
|
DisplayName: displayName,
|
|
}
|
|
|
|
return &newUser, "", nil
|
|
}
|