Skip to content

Commit 15206ba

Browse files
committed
feat(backend): implement oauth login
1 parent baa69e8 commit 15206ba

File tree

6 files changed

+247
-10
lines changed

6 files changed

+247
-10
lines changed

backend/api/oauth.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/base64"
7+
"encoding/gob"
8+
"encoding/json"
9+
"io"
10+
"log/slog"
11+
"net/http"
12+
"os"
13+
"slices"
14+
"time"
15+
16+
"golang.org/x/oauth2"
17+
"golang.org/x/oauth2/endpoints"
18+
)
19+
20+
type DiscordUser struct {
21+
ID string `json:"id"`
22+
Username string `json:"username"`
23+
Avatar string `json:"avatar"`
24+
}
25+
26+
var discordOauthConfig *oauth2.Config
27+
28+
func (a *API) oauthInit() {
29+
gob.Register(DiscordUser{})
30+
baseUrl := ""
31+
if a.Config.RunDev {
32+
baseUrl = "http://localhost:5173"
33+
} else {
34+
baseUrl = "https://status.pluralkit.me"
35+
}
36+
discordOauthConfig = &oauth2.Config{
37+
RedirectURL: baseUrl + "/api/v1/auth/discord/callback",
38+
ClientID: os.Getenv("DISCORD_OAUTH_CLIENT_ID"),
39+
ClientSecret: os.Getenv("DISCORD_OAUTH_CLIENT_SECRET"),
40+
Scopes: []string{"identify"},
41+
Endpoint: endpoints.Discord,
42+
}
43+
}
44+
45+
const discordUserEndpoint = "https://discordapp.com/api/users/@me"
46+
47+
func (a *API) logout(w http.ResponseWriter, r *http.Request) {
48+
err := a.Sessions.Destroy(r.Context())
49+
if err != nil {
50+
a.Logger.Error("failed to destroy session", slog.Any("error", err))
51+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
52+
return
53+
}
54+
http.SetCookie(w, &http.Cookie{
55+
Name: "login_status",
56+
Value: "0",
57+
Path: "/",
58+
MaxAge: -1,
59+
HttpOnly: false,
60+
})
61+
http.Redirect(w, r, "/", http.StatusSeeOther)
62+
}
63+
64+
func (a *API) oauthDiscordLogin(w http.ResponseWriter, r *http.Request) {
65+
state := a.genState(w)
66+
67+
url := discordOauthConfig.AuthCodeURL(state)
68+
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
69+
}
70+
71+
func (a *API) oauthDiscordCallback(w http.ResponseWriter, r *http.Request) {
72+
state, err := r.Cookie("oauthstate")
73+
if err != nil {
74+
a.Logger.Warn("error while retrieving oauth state", slog.Any("error", err))
75+
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
76+
return
77+
}
78+
79+
if r.FormValue("state") != state.Value {
80+
a.Logger.Warn("invalid oauth state", slog.Any("error", err))
81+
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
82+
return
83+
}
84+
85+
http.SetCookie(w, &http.Cookie{
86+
Name: "oauthstate",
87+
Value: "",
88+
Path: "/",
89+
MaxAge: -1,
90+
HttpOnly: true,
91+
Secure: !a.Config.RunDev,
92+
SameSite: http.SameSiteLaxMode,
93+
})
94+
95+
data, err := getUserData(r.Context(), r.FormValue("code"))
96+
if err != nil {
97+
a.Logger.Error("error while getting user data", slog.Any("error", err))
98+
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
99+
return
100+
}
101+
102+
var user DiscordUser
103+
if err := json.Unmarshal(data, &user); err != nil {
104+
a.Logger.Error("error parsing json", slog.Any("error", err))
105+
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
106+
return
107+
}
108+
109+
if !slices.Contains(a.Config.AuthorizedUsers, user.ID) {
110+
http.Error(w, "You are not authorized to access this resource", http.StatusUnauthorized)
111+
a.Logger.Info("Unauthorized user attempted access", slog.Any("user", user))
112+
return
113+
}
114+
115+
if err := a.Sessions.RenewToken(r.Context()); err != nil {
116+
a.Logger.Error("error renewing token", slog.Any("error", err))
117+
http.Error(w, "Server Error", http.StatusInternalServerError)
118+
return
119+
}
120+
121+
a.Sessions.Put(r.Context(), "user_session", user)
122+
http.SetCookie(w, &http.Cookie{
123+
Name: "login_status",
124+
Value: "1",
125+
Path: "/",
126+
HttpOnly: false,
127+
Secure: !a.Config.RunDev,
128+
MaxAge: 86400,
129+
})
130+
http.Redirect(w, r, "/", http.StatusFound)
131+
}
132+
133+
func (a *API) genState(w http.ResponseWriter) string {
134+
var expiration = time.Now().Add(24 * time.Hour)
135+
136+
b := make([]byte, 16)
137+
rand.Read(b)
138+
state := base64.URLEncoding.EncodeToString(b)
139+
cookie := http.Cookie{
140+
Name: "oauthstate",
141+
Value: state,
142+
Expires: expiration,
143+
HttpOnly: true,
144+
Secure: !a.Config.RunDev,
145+
SameSite: http.SameSiteLaxMode,
146+
}
147+
http.SetCookie(w, &cookie)
148+
149+
return state
150+
}
151+
152+
func getUserData(ctx context.Context, code string) ([]byte, error) {
153+
token, err := discordOauthConfig.Exchange(ctx, code)
154+
if err != nil {
155+
return nil, err
156+
}
157+
158+
client := &http.Client{}
159+
req, err := http.NewRequest("GET", discordUserEndpoint, nil)
160+
if err != nil {
161+
return nil, err
162+
}
163+
164+
token.SetAuthHeader(req)
165+
166+
resp, err := client.Do(req)
167+
if err != nil {
168+
return nil, err
169+
}
170+
defer resp.Body.Close()
171+
172+
content, err := io.ReadAll(resp.Body)
173+
if err != nil {
174+
return nil, err
175+
}
176+
return content, nil
177+
}

backend/api/routes.go

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import (
77
"net/http"
88
"pluralkit/status/db"
99
"pluralkit/status/util"
10+
"slices"
1011
"strings"
1112
"sync"
1213
"time"
1314

15+
"github.com/alexedwards/scs/v2"
1416
"github.com/go-chi/chi/v5"
1517
)
1618

@@ -64,6 +66,7 @@ type API struct {
6466
Config util.Config
6567
Logger *slog.Logger
6668
Database *db.DB
69+
Sessions *scs.SessionManager
6770
httpClient http.Client
6871

6972
clustersCache ClustersInfo
@@ -73,10 +76,19 @@ type API struct {
7376

7477
func NewAPI(config util.Config, logger *slog.Logger, database *db.DB) *API {
7578
moduleLogger := logger.With(slog.String("module", "API"))
79+
80+
sessionManager := scs.New()
81+
sessionManager.Lifetime = 24 * time.Hour
82+
sessionManager.Cookie.Name = "session_id"
83+
sessionManager.Cookie.HttpOnly = true
84+
sessionManager.Cookie.Secure = !config.RunDev
85+
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
86+
7687
return &API{
7788
Config: config,
7889
Logger: moduleLogger,
7990
Database: database,
91+
Sessions: sessionManager,
8092
httpClient: http.Client{Timeout: 10 * time.Second},
8193
clustersCache: ClustersInfo{
8294
Clusters: make([]*Cluster, 0),
@@ -85,9 +97,7 @@ func NewAPI(config util.Config, logger *slog.Logger, database *db.DB) *API {
8597
}
8698
}
8799

88-
// this isn't that secure, and it's not supposed to be.
89-
// backend should be behind a reverse proxy with only local/certain IPs allowed
90-
func BasicTokenAuth(token string) func(http.Handler) http.Handler {
100+
func (a *API) Auth() func(http.Handler) http.Handler {
91101
return func(next http.Handler) http.Handler {
92102
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93103
authHeader := r.Header.Get("Authorization")
@@ -101,16 +111,55 @@ func BasicTokenAuth(token string) func(http.Handler) http.Handler {
101111
return
102112
}
103113

104-
if split[1] == token {
114+
if split[1] == a.Config.AuthToken {
105115
next.ServeHTTP(w, r)
106-
} else {
116+
return
117+
}
118+
119+
val := a.Sessions.Get(r.Context(), "user_session")
120+
user, ok := val.(DiscordUser)
121+
if !ok {
122+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
123+
return
124+
}
125+
126+
if user.ID == "" || !slices.Contains(a.Config.AuthorizedUsers, user.ID) {
107127
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
128+
return
108129
}
130+
131+
next.ServeHTTP(w, r)
109132
})
110133
}
111134
}
112135

136+
func (a *API) Me(w http.ResponseWriter, r *http.Request) {
137+
val := a.Sessions.Get(r.Context(), "user_session")
138+
user, ok := val.(DiscordUser)
139+
140+
if !ok {
141+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
142+
return
143+
}
144+
145+
var avatarURL string
146+
if user.Avatar != "" {
147+
avatarURL = "https://cdn.discordapp.com/avatars/" + user.ID + "/" + user.Avatar + ".png"
148+
} else {
149+
avatarURL = "https://cdn.discordapp.com/embed/avatars/0.png"
150+
}
151+
152+
response := map[string]string{
153+
"id": user.ID,
154+
"username": user.Username,
155+
"avatar": avatarURL,
156+
}
157+
w.Header().Set("Content-Type", "application/json")
158+
json.NewEncoder(w).Encode(response)
159+
}
160+
113161
func (a *API) SetupRoutes(router *chi.Mux) {
162+
a.oauthInit()
114163
router.Route("/api/v1", func(r chi.Router) {
115164

116165
r.Get("/status", a.GetStatus)
@@ -131,12 +180,15 @@ func (a *API) SetupRoutes(router *chi.Mux) {
131180
r.Get("/", a.GetUpdate)
132181
})
133182

183+
r.Route("/auth/discord", func(r chi.Router) {
184+
r.Get("/login", a.oauthDiscordLogin)
185+
r.Get("/callback", a.oauthDiscordCallback)
186+
r.Get("/logout", a.logout)
187+
})
188+
r.Get("/me", a.Me)
189+
134190
r.Route("/admin", func(r chi.Router) {
135-
if a.Config.AuthToken != "" {
136-
r.Use(BasicTokenAuth(a.Config.AuthToken))
137-
} else {
138-
a.Logger.Warn("auth token is not set! admin endpoint auth disabled!")
139-
}
191+
r.Use(a.Auth())
140192

141193
r.Route("/incidents", func(r chi.Router) {
142194
r.Post("/create", a.CreateIncident)

backend/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717

1818
require (
1919
github.com/ajg/form v1.5.1 // indirect
20+
github.com/alexedwards/scs/v2 v2.9.0
2021
github.com/davecgh/go-spew v1.1.1 // indirect
2122
github.com/fatih/color v1.18.0 // indirect
2223
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
@@ -34,6 +35,7 @@ require (
3435
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
3536
golang.org/x/crypto v0.33.0 // indirect
3637
golang.org/x/net v0.34.0 // indirect
38+
golang.org/x/oauth2 v0.34.0 // indirect
3739
golang.org/x/sys v0.30.0 // indirect
3840
golang.org/x/text v0.22.0 // indirect
3941
gopkg.in/yaml.v3 v3.0.1 // indirect

backend/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
22
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
3+
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
4+
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
35
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
46
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
57
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -59,6 +61,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
5961
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
6062
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
6163
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
64+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
65+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
6266
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
6367
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
6468
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

backend/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ func main() {
9898
r.Use(middleware.Timeout(30 * time.Second))
9999

100100
apiInstance := api.NewAPI(cfg, logger, db)
101+
r.Use(apiInstance.Sessions.LoadAndSave)
101102
apiInstance.SetupRoutes(r)
102103

103104
if cfg.RunDev {

backend/util/util.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ type Config struct {
3636
RunDev bool `env:"pluralkit__status__run_dev" envDefault:"false"`
3737
DBLoc string `env:"pluralkit__status__db_location" envDefault:"file:status.db?_foreign_keys=on"`
3838
LogLevel SlogLevel `env:"pluralkit__consoleloglevel" envDefault:"info"`
39+
AuthorizedUsers []string `env:"pluralkit__status__auth_users"`
3940
}

0 commit comments

Comments
 (0)