Skip to content

Commit aedfd2d

Browse files
committed
feat: support proxy auth for config editor api
1 parent 726abdb commit aedfd2d

2 files changed

Lines changed: 390 additions & 10 deletions

File tree

web/api.go

Lines changed: 203 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import (
88
"io"
99
"io/fs"
1010
"mime"
11+
"net"
1112
"net/http"
13+
"net/netip"
1214
"os"
1315
"path"
1416
"path/filepath"
17+
"strings"
1518
"time"
1619

1720
"github.com/gin-contrib/static"
@@ -30,6 +33,12 @@ var storageDownload = storage.Download
3033

3134
var errConfigPathNotFound = errors.New("config file not found")
3235

36+
const (
37+
configEditorAuthModeBasic = "basic"
38+
configEditorAuthModeProxy = "proxy"
39+
configEditorAuthModeBasicOrProxy = "basic_or_proxy"
40+
)
41+
3342
type embedFileSystem struct {
3443
http.FileSystem
3544
indexes bool
@@ -79,8 +88,8 @@ func StartHTTP(version string) (err error) {
7988

8089
r := setupRouter(version)
8190

82-
// Enable baseAuth
83-
if len(config.Web.Username) > 0 && len(config.Web.Password) > 0 {
91+
// Enable BasicAuth for the built-in auth mode.
92+
if configEditorAuthMode() == configEditorAuthModeBasic && hasBasicAuthConfig() {
8493
r.Use(gin.BasicAuth(gin.Accounts{
8594
config.Web.Username: config.Web.Password,
8695
}))
@@ -121,6 +130,7 @@ func setupRouter(version string) *gin.Engine {
121130
})
122131

123132
group := r.Group("/api")
133+
group.Use(requireAPIAuth)
124134
group.GET("/config", getConfig)
125135
configGroup := group.Group("/config")
126136
configGroup.Use(requireConfigEditorAuth)
@@ -136,25 +146,209 @@ func setupRouter(version string) *gin.Engine {
136146
}
137147

138148
func requireConfigEditorAuth(c *gin.Context) {
139-
if len(config.Web.Username) == 0 || len(config.Web.Password) == 0 {
149+
mode := configEditorAuthMode()
150+
switch mode {
151+
case configEditorAuthModeBasic:
152+
requireConfigEditorBasicAuth(c)
153+
case configEditorAuthModeProxy:
154+
requireConfigEditorProxyAuth(c)
155+
case configEditorAuthModeBasicOrProxy:
156+
if checkConfigEditorBasicAuth(c) || checkConfigEditorProxyAuth(c) {
157+
c.Next()
158+
return
159+
}
160+
abortConfigEditorAuthRequired(c)
161+
default:
140162
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
141-
"message": "Config editor requires API authentication.",
163+
"message": "Unsupported config editor authentication mode.",
142164
})
165+
}
166+
}
167+
168+
func requireAPIAuth(c *gin.Context) {
169+
mode := configEditorAuthMode()
170+
switch mode {
171+
case configEditorAuthModeBasic:
172+
c.Next()
173+
case configEditorAuthModeProxy:
174+
if checkConfigEditorProxyAuth(c) {
175+
c.Next()
176+
return
177+
}
178+
abortConfigEditorProxyAuthRequired(c)
179+
case configEditorAuthModeBasicOrProxy:
180+
if checkConfigEditorBasicAuth(c) || checkConfigEditorProxyAuth(c) {
181+
c.Next()
182+
return
183+
}
184+
abortConfigEditorAuthRequired(c)
185+
default:
186+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
187+
"message": "Unsupported config editor authentication mode.",
188+
})
189+
}
190+
}
191+
192+
func configEditorAuthMode() string {
193+
mode := strings.TrimSpace(config.Web.AuthMode)
194+
if mode == "" {
195+
return configEditorAuthModeBasic
196+
}
197+
198+
return mode
199+
}
200+
201+
func hasBasicAuthConfig() bool {
202+
return config.Web.Username != "" && config.Web.Password != ""
203+
}
204+
205+
func requireConfigEditorBasicAuth(c *gin.Context) {
206+
if !hasBasicAuthConfig() {
207+
abortConfigEditorAuthRequired(c)
143208
return
144209
}
145210

211+
if !checkConfigEditorBasicAuth(c) {
212+
abortConfigEditorBasicAuthRequired(c)
213+
return
214+
}
215+
216+
c.Next()
217+
}
218+
219+
func checkConfigEditorBasicAuth(c *gin.Context) bool {
220+
if !hasBasicAuthConfig() {
221+
return false
222+
}
223+
146224
username, password, ok := c.Request.BasicAuth()
147-
if !ok || username != config.Web.Username || password != config.Web.Password {
148-
c.Header("WWW-Authenticate", `Basic realm="Authorization Required"`)
149-
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
150-
"message": "Authentication required.",
151-
})
225+
return ok && username == config.Web.Username && password == config.Web.Password
226+
}
227+
228+
func requireConfigEditorProxyAuth(c *gin.Context) {
229+
if !hasProxyAuthConfig() {
230+
abortConfigEditorAuthRequired(c)
231+
return
232+
}
233+
234+
if !checkConfigEditorProxyAuth(c) {
235+
abortConfigEditorProxyAuthRequired(c)
152236
return
153237
}
154238

155239
c.Next()
156240
}
157241

242+
func hasProxyAuthConfig() bool {
243+
proxyAuth := config.Web.ProxyAuth
244+
return proxyAuth.UserHeader != "" && len(proxyAuth.TrustedProxies) > 0 && (len(proxyAuth.AllowedUsers) > 0 || len(proxyAuth.AllowedGroups) > 0)
245+
}
246+
247+
func checkConfigEditorProxyAuth(c *gin.Context) bool {
248+
if !hasProxyAuthConfig() || !requestFromTrustedProxy(c.Request) {
249+
return false
250+
}
251+
252+
user, ok := singleHeaderValue(c.Request.Header, config.Web.ProxyAuth.UserHeader)
253+
if !ok {
254+
return false
255+
}
256+
257+
if stringInSlice(user, config.Web.ProxyAuth.AllowedUsers) {
258+
return true
259+
}
260+
261+
groupHeader := strings.TrimSpace(config.Web.ProxyAuth.GroupHeader)
262+
if groupHeader == "" || len(config.Web.ProxyAuth.AllowedGroups) == 0 {
263+
return false
264+
}
265+
266+
groupsValue, ok := singleHeaderValue(c.Request.Header, groupHeader)
267+
if !ok {
268+
return false
269+
}
270+
271+
for _, group := range strings.Split(groupsValue, ",") {
272+
if stringInSlice(strings.TrimSpace(group), config.Web.ProxyAuth.AllowedGroups) {
273+
return true
274+
}
275+
}
276+
277+
return false
278+
}
279+
280+
func requestFromTrustedProxy(r *http.Request) bool {
281+
host, _, err := net.SplitHostPort(r.RemoteAddr)
282+
if err != nil {
283+
host = r.RemoteAddr
284+
}
285+
286+
ip, err := netip.ParseAddr(strings.TrimSpace(host))
287+
if err != nil {
288+
return false
289+
}
290+
291+
for _, trustedProxy := range config.Web.ProxyAuth.TrustedProxies {
292+
trustedProxy = strings.TrimSpace(trustedProxy)
293+
if trustedProxy == "" {
294+
continue
295+
}
296+
297+
if prefix, err := netip.ParsePrefix(trustedProxy); err == nil {
298+
if prefix.Contains(ip) {
299+
return true
300+
}
301+
continue
302+
}
303+
304+
trustedIP, err := netip.ParseAddr(trustedProxy)
305+
if err == nil && trustedIP == ip {
306+
return true
307+
}
308+
}
309+
310+
return false
311+
}
312+
313+
func singleHeaderValue(header http.Header, name string) (string, bool) {
314+
values, ok := header[http.CanonicalHeaderKey(strings.TrimSpace(name))]
315+
if !ok || len(values) != 1 {
316+
return "", false
317+
}
318+
319+
value := strings.TrimSpace(values[0])
320+
return value, value != ""
321+
}
322+
323+
func stringInSlice(value string, values []string) bool {
324+
for _, candidate := range values {
325+
if value == strings.TrimSpace(candidate) {
326+
return true
327+
}
328+
}
329+
330+
return false
331+
}
332+
333+
func abortConfigEditorAuthRequired(c *gin.Context) {
334+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
335+
"message": "Config editor requires API authentication.",
336+
})
337+
}
338+
339+
func abortConfigEditorBasicAuthRequired(c *gin.Context) {
340+
c.Header("WWW-Authenticate", `Basic realm="Authorization Required"`)
341+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
342+
"message": "Authentication required.",
343+
})
344+
}
345+
346+
func abortConfigEditorProxyAuthRequired(c *gin.Context) {
347+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
348+
"message": "Proxy authentication required.",
349+
})
350+
}
351+
158352
// GET /api/config
159353
func getConfig(c *gin.Context) {
160354
models := map[string]any{}
@@ -188,7 +382,6 @@ type configPathStatusEntry struct {
188382
Exists bool `json:"exists"`
189383
}
190384

191-
192385
func configPathStatuses() []configPathStatus {
193386
knownPaths := config.KnownConfigFilePaths()
194387
statuses := make([]configPathStatus, 0, len(knownPaths))

0 commit comments

Comments
 (0)