-
-
Notifications
You must be signed in to change notification settings - Fork 807
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(auth): reverse proxy authentication support - #176 #1152
Conversation
Thanks for this! Will take a look in the next couple of days. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is great!! Some minor things here and there but very good overall.
Can you also make a PR to the documentation site, at least to add the config options? https://github.com/navidrome/website
Thanks!
conf/configuration.go
Outdated
@@ -117,6 +120,11 @@ func Load() { | |||
os.Exit(1) | |||
} | |||
|
|||
if Server.ReverseProxyUserHeader == "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defaults should be set with viper.SetDefault()
. See examples on line ~200
conf/configuration.go
Outdated
if Server.ReverseProxyUserHeader == "" { | ||
Server.ReverseProxyUserHeader = "Remote-User" | ||
} | ||
Server.ReverseProxyUserHeader = http.CanonicalHeaderKey(Server.ReverseProxyUserHeader) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm pretty sure you don't need to call CanonicalHeaderKey
, as Header.Get
will match any case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Header.Get
will do, but user may set it un-canonical way in config (i.e. remote-user
). But I agree, it shouldn't go there, I'll perform the check where I actually need the header.
edit: Actually never-mind, you are completely right - I'm already doing Get
where necessary, so this is not required here :)
server/app/auth.go
Outdated
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) (payload map[string]interface{}, err error) { | ||
var errIPNotInWhitelist = errors.New("IP is not whitelisted for reverse proxy login") | ||
if !validateIPAgainstList(r.RemoteAddr, conf.Server.ReverseProxyWhitelist) { | ||
log.Info(errIPNotInWhitelist.Error(), "ip", r.RemoteAddr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The syntax for passing an error to log
would be:
log.Error("message", key, value, key, value, err)
But in this case, I don't think you need to create the error variable. I'd use:
log.Error("IP is not whitelisted for reverse proxy login", "ip", r.RemoteAddr)
server/app/auth.go
Outdated
userRepo := ds.User(r.Context()) | ||
user, err := userRepo.FindByUsername(username) | ||
if user == nil || err != nil { | ||
log.Info("User passed in header not found", "user", username) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be Warning or Error
server/app/auth.go
Outdated
|
||
err = userRepo.UpdateLastLoginAt(user.ID) | ||
if err != nil { | ||
log.Error("Could not update LastLoginAt", "user", username) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add the err
as a last parameter to the log in this case, to show in the logs what was the error
server/app/auth.go
Outdated
bytes := make([]byte, 3) | ||
_, err = rand.Read(bytes) | ||
if err != nil { | ||
log.Error("Could not create subsonic salt", "user", username) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add the err as a last parameter to the log in this case, to show in the logs what was the error
server/app/auth.go
Outdated
@@ -40,6 +46,56 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { | |||
} | |||
} | |||
|
|||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) (payload map[string]interface{}, err error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you are explicitly ignoring the error of this function, we can remove it and in the case it is not a valid "login from headers", it just return a nil
payload.
} | ||
ip = strings.Split(ip, ":")[0] | ||
cidrs := strings.Split(comaSeparatedList, ",") | ||
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: does this handles IPv6? Also, why not make the ReverseProxyWhitelist
a list of Host IPs, instead of Network IPs? Would it simplify things, for both the user and the code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll fix IPv6 support, I completely forgot about it. Regarding Host IPs, I'm afraid I don't understand the question.
My understanding is, that r.RemoteAddr
holds the IP of the connecting client (i.e. "raw" IP, not the one passed via X-*
headers). So in this case it should be a proxy IP. We can check this proxy IP against whitelist to ensure, that no other party is trying to tamper the headers and log in bypassing authentication, by connecting directly to the app.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By "Host IP" I mean single address as opposed to a network address (with /size). Rephrasing my question: Why we have to specify the size of the network in each item of ReverseProxyWhitelist
(like 192.168.1.1/32
)? Why not just a list of IPs like 192.168.1.1,fdf8:f53b:82e4::53
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because sometimes (especially in docker containers) user may not be sure, what IP his proxy has. He only knows that it's in some specific subnet. Also, most of the projects (like gitea or grafana) which implements this feature, use CIDRs, so I believe it's somehow a standard.
server/app/serve_index.go
Outdated
@@ -20,6 +20,8 @@ import ( | |||
func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { | |||
policy := bluemonday.UGCPolicy() | |||
return func(w http.ResponseWriter, r *http.Request) { | |||
payload, _ := handleLoginFromHeaders(ds, r) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small nitpick: payload
here should be called auth
ui/src/authProvider.js
Outdated
@@ -5,6 +5,20 @@ import { baseUrl } from './utils' | |||
import config from './config' | |||
import { startEventStream, stopEventStream } from './eventStream' | |||
|
|||
if ( | |||
config.auth && | |||
/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(config.auth.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of validating the ID, I think we should validate the JWT token, as it is more important for the whole authentication process. You could use the jwtDecode
function, already imported. It throws an exception if the token is invalid
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem is that jwtDecode
would throw exception which may break module load. I'll keep this if
condition for pre-check that we actually have auth to check, and I'll add jwtDecode
inside of the if
block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could do something like:
if (config.auth) {
try {
jwtDecode(config.auth.token)
localStorage.setItem('token', config.auth.token)
localStorage.setItem('userId', config.auth.id)
...
} catch(e) {
console.log(e)
}
}
This would not break the module load, right?
server/app/serve_index.go
Outdated
@@ -37,6 +39,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { | |||
"version": consts.Version(), | |||
"firstTime": firstTime, | |||
"baseURL": policy.Sanitize(strings.TrimSuffix(conf.Server.BaseURL, "/")), | |||
"auth": payload, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One more thing: This now appears in debug logs:
DEBU[0004] UI configuration appConfig="map[auth:map[id:dfd95934-35fb-41e1-801e-710f6dafd9bd isAdmin:true message:User 'admin' authenticated successfully name:Dev Admin subsonicSalt:f6e4ae subsonicToken:c52d8017ba5b8791847e27785ad7a4af token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG0iOnRydWUsImV4cCI6MTYyMzE5MjEwMywiaWF0IjoxNjIzMTA1NzAzLCJpc3MiOiJORCIsInN1YiI6ImFkbWluIiwidWlkIjoiZGZkOTU5MzQtMzVmYi00MWUxLTgwMWUtNzEwZjZkYWZkOWJkIn0.8C7Pv-rwcWWBYWF7rORaxPb41r_ssTLiliiq_Rt7SYI username:admin] baseURL: defaultTheme:Dark devActivityPanel:true devEnableShare:false devFastAccessCoverArt:true enableDownloads:true enableFavourites:true enableStarRating:true enableTranscodingConfig:false enableUserEditing:false firstTime:false gaTrackingId: loginBackgroundURL:https://source.unsplash.com/collection/20072696/1600x900 losslessFormats:FLAC,WAV,ALAC,APE,DSF,WAV,SHN,WV,WVP version:dev welcomeMessage:Long long long long String <br/>Another String]"
Tokens (and the salt) must be redacted. You can find the redaction list here:
Line 21 in 39d68e8
RedactionList: []string{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added regexes to log/log.go
but creds are still not redacted - am I doing something wrong? I even tested those regexes manually, and they should catch proper strings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll take a look later tonight. Maybe it is a limitation of the redacting code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was an issue with the redactor. I fixed it and pushed to your branch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. Can you also please submit a PR to the docs site, to add the two new config options?
Sorry, completely forgot about this :( Added documentation here navidrome/website#48 . English is not my primary language, so please ensure that what I wrote actually makes sense :) |
Thanks! I'll review and do any changes to the docs before releasing. |
I've actually just tested this feature because it's super exciting to me and I'm really thankfull for the work, however somehow in my case the IP whitelisting doesn't take the right header into account. With the same reverse proxy setup - Traefik on VPS sending requests through a Wireguard Connection to a NAS running Navidrome via Docker (deluan/navidrome:sha-d3db41a) - I'm seeing this as the output of an exemplary whoami container
My docker-compose looks the following:
but the log output shows: It seems its somehow not taking the RemoteAddr header, but rather X-Real-Ip or X-Forwarded-For into account. Setting ND_REVERSEPROXYWHITELIST to 0.0.0.0/0 works as expected. |
Drat, looks like it's problem with CHI middleware: https://github.com/go-chi/chi/blob/f71cd7d61909bbe19452b6104e76a4c4bf343e8e/middleware/realip.go#L32 . Unfortunately I'm not familar with this framework - maybe @deluan can provide help here. I agree, it's a bug, only "raw IP" should be taken into consideration, without looking at |
Humm, yeah, I'm pretty sure the |
…on feature Should fix #1152 (comment)
Hey @henniaufmrenni, can you try with the latest build? |
Hey just tested the latest build (sha-86271f0 ) and I can confirm that it's working 🎉
|
Are there docs for setting this up with Keycloak or Authelia? |
Documenting this is out of the scope of navidrome, as every provider does it on its own way. I suggest, to refer to corresponding documentation of given identity provider. On navidrome side, you only need to set two configuration options (which are documented):
There is also a tutorial for vouch, it should provide some extra help how to start: https://old.reddit.com/r/navidrome/comments/oa8gkz/guide_how_to_use_a_sso_solution_in_front_of/ Good luck! |
I see, thanks. I think it would be a good idea to add an example for Vouch in the docs so people get an idea. For those of you using Keycloak + External Auth Server + Traefik, I had to do the following: // /home/eas/app/bin/generate-config-token.js
let config_token = {
eas: {
plugins: [
// firstly whitelist /rest/* paths for subsonic client apps
{
type: "request_js",
snippet: "if (parentReqInfo.parsedUri.path.startsWith('/rest') == true) res.statusCode = 200;"
},
{
type: "oidc",
...
custom_service_headers: {
"X-Auth-Username": {
source: "userinfo",
query_engine: "jp",
query: "$.preferred_username",
query_opts: {
single_value: true
},
}
}
... # docker-compose.yml
services:
navidrome:
...
environment:
ND_REVERSEPROXYWHITELIST: "172.19.0.0/16"
ND_REVERSEPROXYUSERHEADER: "X-Auth-Username"
...
labels:
- "traefik.enable=true"
- "traefik.http.routers.navidrome.rule=Host(`example.com`)"
- "traefik.http.routers.navidrome.entrypoints=websecure"
- "traefik.http.routers.navidrome.tls.certresolver=myresolver"
- "traefik.http.routers.navidrome.middlewares=navidrome-auth"
# auth middleware
- "traefik.http.middlewares.navidrome-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.navidrome-auth.forwardauth.authResponseHeaders=X-Userinfo, X-Auth-Username, X-Id-Token, X-Access-Token, Authorization"
- "traefik.http.middlewares.navidrome-auth.forwardauth.address=https://auth.example.com/verify?config_token=<token generated from running 'node generate-config-token.js'>" edit: For the time being I've disabled this. I'm not sure if it's an EAS setting but I constantly have to log in with my external auth and Navidrome doesn't handle this well; I just have to refresh when I notice Navidrome showing errors about loading songs. |
Hi, I have managed to get this working with my own setup, so thank you! However, I was just wondering if it is possible to create a user if one doesn't already exist? By this, I mean I am getting this error for a user that successfully authenticates using my reverse proxy authentication, but does not exist in the navidrome database: Line 286 in aa1571e
|
Currently it's not possible, and I'm not sure if it's a good idea either. Registration should be a conscious act (either of a user or of an admin). And to be honest, I've never seen setup like that. I think it's more a question to @deluan - how the app should behave in this case. |
This is very common for software that integrates LDAP or OIDC flows. Off the top of my head, I can say that Airflow, Jupyterhub, and Gitea support this flow either from configuring LDAP alone or configuring LDAP + some config settings. Those also allow setting user roles or admin status via LDAP filters. This is also known as RBAC - Role Based Authentication. So I don't think it's unusual to allow creating a user + setting roles here via http headers, so long as the source is trusted via the whitelist.
That is already handled by LDAP/OIDC. Right now you would have to 1) set up the user in Keycloak and then 2) set up the user, again, in Navidrome. It's redundant and, in my experience, confuses users because they have to register twice. What I would recommend for Navidrome is having an environment variable like |
Sounds reasonable - I'll take a look into that on a weekend 👍 . |
The issue with creating a user without a password (or an auto-generated one) is that the Subsonic API will not work.... There is some initial work (#1262) from @mikhail5555 that is the first step towards the goal to allow each client/player to have their own password, but it is not possible yet. While we don't have this ability, I wouldn't spend time working on this By the way, what about creating a new issue to track this feature request? Let's avoid discussing new features on closed PRs/issues |
This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. |
This is an attempt to address an issue described in #176 . Basically it allows auto-login user using forwarding proxy manager like authelia or vouch. This PR introduces two new config options:
ReverseProxyUserHeader
- HTTP header containing user name from authenticated proxy. Default:Remote-User
ReverseProxyWhitelist
- Coma separated lists of IP CIDRs which are allowed to use this feature - usually should contain reverse proxy IP with mask. Setting this to0.0.0.0/0
would allow any connection to use the header (which is discouraged). By default everything is blacklisted, which means, this config parameter must be set to actually use the feature.