feat: "local only" automatic authentication

Often you want to expose your library publicly, which requires
authentication to prevent unknown users from seeing your
content.

In my case, I also expose the library on a local domain using a local DNS server which doesn't have these security issues.

This change adds a `local_only` option to the feed auth
config which will only supply the provided username/password
when the request comes from a private IP address.

Omitting `local_only` or setting to false will keep the current
logic of sending the credentials no matter the origin of the
request.
This commit is contained in:
Evan Buss
2024-08-10 21:13:57 +00:00
parent ccc6217014
commit e21a648506
4 changed files with 80 additions and 31 deletions

View File

@@ -14,7 +14,7 @@ tmp_dir = "tmp"
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_ext = ["go", "tpl", "tmpl", "html", "css", "yml"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"

View File

@@ -41,13 +41,17 @@ auth:
feeds:
- name: Some Feed
url: http://some-feed.com/opds
# Optional Credentials
# (Optional) Feed Authentication Credentials
# If present, users will not be prompted for credentials in the web interface.
# The server will take care of sending these with requests to the feed URL.
# This is useful if you want to make all feeds public or provide a single authentication
# layer in front of OPDS Proxy without having users remember multiple logins for individual feeds.
username: user
password: password
auth:
username: user
password: password
# (Optional) Only provide the credentials when request comes from private IP address
# Requires all `X-Forwarded-For` IPs to be private. Make sure you trust your reverse proxy chain.
local_only: true
- name: Some Other feed
url: http://some-other-feed.com/opds
```
@@ -56,13 +60,13 @@ Some config options can be set via command flags. These take precedence over the
```shell
# To set the port via flags
opds-proxy -port 5228
opds-proxy --port 5228
# To generate new cookie keys and exit
opds-proxy -generate-keys
opds-proxy --generate-keys
# To use a config file that isn't named `config.yml` in the current path
opds-proxy -config ~/.config/opds-proxy-config.yml
opds-proxy --config ~/.config/opds-proxy-config.yml
```
@@ -99,3 +103,18 @@ That being said, the Kindle / Kobo native reader software is faster, better look
- Doesn't connect to your existing library
- Single User
- Can't share library with friends / family
## Known Issues
Tested and confirmed working with Calibre's OPDS Feed. Any others may have issues. Please submit issues for any bugs you encounter.
### Browser Quirks
eReader browsers are extremely basic and outdated.
Kobo Browser:
- Basic Authentication not supported.
- Cookies are cleared when browser is closed so you have to log in every time.
- Cookies don't support `secure` or `httponly`. They just silently fail to be saved.
- Modern CSS layouts like flexbox not supported.
- Javascript mostly doesn't work.
- Worst of all, each time a link is clicked 2 requests are sent for the same URL.

13
main.go
View File

@@ -28,10 +28,15 @@ type AuthConfig struct {
}
type FeedConfig struct {
Name string `koanf:"name"`
Url string `koanf:"url"`
Username string `koanf:"username"`
Password string `koanf:"password"`
Name string `koanf:"name"`
Url string `koanf:"url"`
Auth *FeedConfigAuth `koanf:"auth"`
}
type FeedConfigAuth struct {
Username string `koanf:"username"`
Password string `koanf:"password"`
LocalOnly bool `koanf:"local_only"`
}
func main() {

View File

@@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"os"
@@ -49,9 +50,12 @@ type Credentials struct {
type contextKey string
const (
requestLogger = contextKey("log")
requestLogger = contextKey("requestLogger")
isLocalRequest = contextKey("isLocalRequest")
)
const cookieName = "auth-creds"
func NewServer(config *ProxyConfig) (*Server, error) {
hashKey, err := hex.DecodeString(config.Auth.HashKey)
if err != nil {
@@ -69,9 +73,9 @@ func NewServer(config *ProxyConfig) (*Server, error) {
s := securecookie.New(hashKey, blockKey)
router := http.NewServeMux()
router.Handle("GET /{$}", logger(handleHome(config.Feeds)))
router.Handle("GET /feed", logger(handleFeed("tmp/", config.Feeds, s)))
router.Handle("/auth", logger(handleAuth(s)))
router.Handle("GET /{$}", requestMiddleware(handleHome(config.Feeds)))
router.Handle("GET /feed", requestMiddleware(handleFeed("tmp/", config.Feeds, s)))
router.Handle("/auth", requestMiddleware(handleAuth(s)))
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
return &Server{
@@ -81,7 +85,7 @@ func NewServer(config *ProxyConfig) (*Server, error) {
}, nil
}
func logger(next http.Handler) http.Handler {
func requestMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
id := uuid.New()
@@ -90,8 +94,18 @@ func logger(next http.Handler) http.Handler {
requestIP = r.RemoteAddr
}
query, _ := url.QueryUnescape(r.URL.RawQuery)
isLocal := true
for _, addr := range strings.Split(requestIP, ", ") {
host, _, _ := net.SplitHostPort(addr)
ip := net.ParseIP(host)
if ip == nil || (!ip.IsPrivate() && !ip.IsLoopback()) {
isLocal = false
break
}
}
ctx := context.WithValue(r.Context(), isLocalRequest, isLocal)
query, _ := url.QueryUnescape(r.URL.RawQuery)
log := slog.With(
slog.Group("request",
slog.String("id", id.String()),
@@ -103,7 +117,9 @@ func logger(next http.Handler) http.Handler {
),
)
r = r.WithContext(context.WithValue(r.Context(), requestLogger, log))
ctx = context.WithValue(ctx, requestLogger, log)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
log.Info("Request Completed", slog.String("duration", time.Since(start).String()))
})
@@ -267,13 +283,13 @@ func handleAuth(s *securecookie.SecureCookie) http.HandlerFunc {
domain.Hostname(): {Username: username, Password: password},
}
encoded, err := s.Encode("auth-creds", value)
encoded, err := s.Encode(cookieName, value)
if err != nil {
handleError(r, w, "Failed to encode credentials", err)
return
}
cookie := &http.Cookie{
Name: "auth-creds",
Name: cookieName,
Value: encoded,
Path: "/",
// Kobo fails to set cookies with HttpOnly or Secure flags
@@ -295,39 +311,48 @@ func getCredentials(r *http.Request, feeds []FeedConfig, s *securecookie.SecureC
return nil
}
feedUrl, err := url.Parse(r.URL.Query().Get("q"))
requestUrl, err := url.Parse(r.URL.Query().Get("q"))
if err != nil {
return nil
}
// Try to get credentials from the config first
for _, feed := range feeds {
if feed.Username == "" || feed.Password == "" {
continue
}
configUrl, err := url.Parse(feed.Url)
feedUrl, err := url.Parse(feed.Url)
if err != nil {
continue
}
if configUrl.Hostname() == feedUrl.Hostname() {
return &Credentials{Username: feed.Username, Password: feed.Password}
if feedUrl.Hostname() != requestUrl.Hostname() {
continue
}
// Only set feed credentials for local requests
// when the auth config has local_only flag
isLocal := r.Context().Value(isLocalRequest).(bool)
if !isLocal && feed.Auth.LocalOnly {
continue
}
if feed.Auth == nil || feed.Auth.Username == "" || feed.Auth.Password == "" {
continue
}
return &Credentials{Username: feed.Auth.Username, Password: feed.Auth.Password}
}
// Otherwise, try to get credentials from the cookie
cookie, err := r.Cookie("auth-creds")
cookie, err := r.Cookie(cookieName)
if err != nil {
return nil
}
value := make(map[string]*Credentials)
if err = s.Decode("auth-creds", cookie.Value, &value); err != nil {
if err = s.Decode(cookieName, cookie.Value, &value); err != nil {
return nil
}
return value[feedUrl.Hostname()]
return value[requestUrl.Hostname()]
}
func fetchFromUrl(url string, credentials *Credentials) (*http.Response, error) {