forked from Ivasoft/opds-proxy
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:
@@ -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"
|
||||
|
||||
31
README.md
31
README.md
@@ -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
13
main.go
@@ -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() {
|
||||
|
||||
65
server.go
65
server.go
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user