diff --git a/.gitignore b/.gitignore index 7e4a41e..fca1efc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ .DS_store -# Local environment -.env - # IntelliJ / GoLand .idea/ +# Local environment +.env + # Local outputs build/ *.log diff --git a/Dockerfile b/Dockerfile index 290ef1b..f881a05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,3 @@ -# syntax = docker/dockerfile:1.2 FROM golang:1.22 AS builder diff --git a/docker-compose.yml b/docker-compose.yml index b664022..fe40cf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ -version: "3" services: irods-server: container_name: irods-server image: "ghcr.io/wtsi-npg/ub-16.04-irods-4.2.7:latest" + platform: linux/amd64 ports: - "127.0.0.1:1247:1247" - "127.0.0.1:20000-20199:20000-20199" @@ -25,9 +25,21 @@ services: "--cert-file", "/app/config/localhost.crt", "--key-file", "/app/config/localhost.key", "--irods-env", "/app/config/app_irods_environment.json", + "--enable-oidc", "--log-level", "trace"] - environment: - IRODS_PASSWORD: "irods" # Required when the app auth file is not present + # Set the following environment variables in a .env file (files named .env + # are declared in .gitignore): + # + # If no iRODS auth file is provided: + # + # IRODS_PASSWORD + # + # And if using OIDC: + # + # OIDC_CLIENT_ID + # OIDC_CLIENT_SECRET + # OIDC_ISSUER_URL + env_file: .env ports: - "3333:3333" volumes: diff --git a/server/handlers.go b/server/handlers.go index 5257298..31f1725 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -27,6 +27,8 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + ifs "github.com/cyverse/go-irodsclient/fs" + "github.com/cyverse/go-irodsclient/irods/types" "github.com/rs/xid" "github.com/rs/zerolog/hlog" ) @@ -49,7 +51,12 @@ func HandleHomePage(server *SqyrrlServer) http.Handler { requestMethod := r.Method if requestPath != "/" && requestMethod == "GET" { - redirect := path.Join(EndPointIRODS, requestPath) + if requestPath == "/favicon.ico" { + writeErrorResponse(logger, w, http.StatusNotFound) + return + } + + redirect := path.Join(EndpointIRODS, requestPath) logger.Trace(). Str("from", requestPath). Str("to", redirect). @@ -77,8 +84,8 @@ func HandleHomePage(server *SqyrrlServer) http.Handler { } data := pageData{ - LoginURL: EndPointLogin, - LogoutURL: EndPointLogout, + LoginURL: EndpointLogin, + LogoutURL: EndpointLogout, AuthAvailable: server.sqyrrlConfig.EnableOIDC, Authenticated: server.isAuthenticated(r), UserName: server.getSessionUserName(r), @@ -278,7 +285,68 @@ func HandleIRODSGet(server *SqyrrlServer) http.Handler { objPath := path.Clean(path.Join("/", r.URL.Path)) logger.Debug().Str("path", objPath).Msg("Getting iRODS data object") - getFileRange(rodsLogger, w, r, server.iRODSAccount, objPath) + var userID string + if server.isAuthenticated(r) { + userID = iRODSUserIDFromEmail(logger, server.getSessionUserEmail(r)) + } else { + userID = IRODSPublicUser + } + + userZone := server.iRODSAccount.ClientZone + + var err error + var rodsFs *ifs.FileSystem + if rodsFs, err = ifs.NewFileSystemWithDefault(server.iRODSAccount, + AppName); err != nil { + logger.Err(err).Msg("Failed to create an iRODS file system") + writeErrorResponse(logger, w, http.StatusInternalServerError) + return + } + defer rodsFs.Release() + + // Don't use filesystem.ExistsFile(objPath) here because it will return false if + // the file _does_ exist on the iRODS server, but the server is down or unreachable. + // + // filesystem.StatFile(objPath) is better because we can check for the error type. + if _, err = rodsFs.Stat(objPath); err != nil { + if types.IsAuthError(err) { + logger.Err(err). + Str("path", objPath). + Msg("Failed to authenticate with iRODS") + writeErrorResponse(logger, w, http.StatusUnauthorized) + return + } + if types.IsFileNotFoundError(err) { + logger.Info(). + Str("path", objPath). + Msg("Requested path does not exist") + writeErrorResponse(logger, w, http.StatusNotFound) + return + } + logger.Err(err).Str("path", objPath).Msg("Failed to stat file") + writeErrorResponse(logger, w, http.StatusInternalServerError) + return + } + + var isReadable bool + isReadable, err = isReadableByUser(logger, rodsFs, userZone, userID, objPath) + if err != nil { + logger.Err(err).Msg("Failed to check if the object is readable") + writeErrorResponse(logger, w, http.StatusInternalServerError) + return + } + + if !isReadable { + logger.Info(). + Str("path", objPath). + Str("id", userID). + Str("zone", userZone). + Msg("Requested path is not readable by this user") + writeErrorResponse(logger, w, http.StatusForbidden) + return + } + + getFileRange(rodsLogger, w, r, rodsFs, objPath) }) } diff --git a/server/handlers_test.go b/server/handlers_test.go index 3d62b2f..01c60ff 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -82,13 +82,14 @@ var _ = Describe("iRODS Get Handler", func() { When("a non-existent path is given", func() { var r *http.Request var handler http.Handler + var err error BeforeEach(func() { - handler = http.StripPrefix(server.EndpointAPI, - server.HandleIRODSGet(testServer)) + handler, err = testServer.GetHandler(server.EndpointIRODS) + Expect(err).NotTo(HaveOccurred()) objPath := path.Join(workColl, "no", "such", "file.txt") - getURL, err := url.JoinPath(server.EndpointAPI, objPath) + getURL, err := url.JoinPath(server.EndpointIRODS, objPath) Expect(err).NotTo(HaveOccurred()) r, err = http.NewRequest("GET", getURL, nil) @@ -106,13 +107,14 @@ var _ = Describe("iRODS Get Handler", func() { When("a valid data object path is given", func() { var r *http.Request var handler http.Handler + var err error BeforeEach(func() { - handler = http.StripPrefix(server.EndpointAPI, - server.HandleIRODSGet(testServer)) + handler, err = testServer.GetHandler(server.EndpointIRODS) + Expect(err).NotTo(HaveOccurred()) objPath := path.Join(workColl, testFile) - getURL, err := url.JoinPath(server.EndpointAPI, objPath) + getURL, err := url.JoinPath(server.EndpointIRODS, objPath) Expect(err).NotTo(HaveOccurred()) r, err = http.NewRequest("GET", getURL, nil) @@ -131,11 +133,10 @@ var _ = Describe("iRODS Get Handler", func() { When("the data object has public read permissions", func() { var conn *connection.IRODSConnection var acl []*types.IRODSAccess - var err error BeforeEach(func() { - handler = http.StripPrefix(server.EndpointAPI, - server.HandleIRODSGet(testServer)) + handler, err = testServer.GetHandler(server.EndpointIRODS) + Expect(err).NotTo(HaveOccurred()) conn, err = irodsFS.GetIOConnection() Expect(err).NotTo(HaveOccurred()) diff --git a/server/irods.go b/server/irods.go index a5dc5a7..f3170fc 100644 --- a/server/irods.go +++ b/server/irods.go @@ -24,7 +24,7 @@ import ( "path/filepath" "time" - "github.com/cyverse/go-irodsclient/fs" + ifs "github.com/cyverse/go-irodsclient/fs" "github.com/cyverse/go-irodsclient/icommands" "github.com/cyverse/go-irodsclient/irods/types" "github.com/cyverse/go-irodsclient/irods/util" @@ -175,14 +175,14 @@ func NewIRODSAccount(logger zerolog.Logger, // Before returning the account, check that it is usable by connecting to the // iRODS server and accessing the root collection. - var filesystem *fs.FileSystem - filesystem, err = fs.NewFileSystemWithDefault(account, AppName) + var filesystem *ifs.FileSystem + filesystem, err = ifs.NewFileSystemWithDefault(account, AppName) if err != nil { logger.Err(err).Msg("Failed to create an iRODS file system") return nil, err } - var root *fs.Entry + var root *ifs.Entry root, err = filesystem.StatDir("/") if err != nil { logger.Err(err).Msg("Failed to stat the root zone collection") @@ -195,15 +195,15 @@ func NewIRODSAccount(logger zerolog.Logger, return account, err } -// isPublicReadable checks if the data object at the given path is readable by the -// public user of the zone hosting the file. +// isReadableByUser checks if the data object at the given path is readable by the +// given user in the zone hosting the file. // -// If iRODS is federated, there may be multiple zones, each with their own public user. -// The zone argument is the zone of public user whose read permission is to be checked, +// If iRODS is federated, there may be multiple zones, each with their own users. +// The zone argument is the zone of user whose read permission is to be checked, // which is normally the current zone. This is consulted only if the ACL user zone is // empty. -func isPublicReadable(logger zerolog.Logger, filesystem *fs.FileSystem, - userZone string, rodsPath string) (_ bool, err error) { +func isReadableByUser(logger zerolog.Logger, filesystem *ifs.FileSystem, + userZone string, userName string, rodsPath string) (_ bool, err error) { var acl []*types.IRODSAccess var pathZone string @@ -224,17 +224,23 @@ func isPublicReadable(logger zerolog.Logger, filesystem *fs.FileSystem, } if effectiveUserZone == pathZone && - ac.UserName == IRODSPublicUser && + ac.UserName == userName && ac.AccessLevel == types.IRODSAccessLevelReadObject { logger.Trace(). Str("path", rodsPath). - Msg("Public read access found") + Str("user", userName). + Str("zone", userZone). + Msg("Read access found") return true, nil } } - logger.Trace().Str("path", rodsPath).Msg("Public read access not found") + logger.Trace(). + Str("path", rodsPath). + Str("user", userName). + Str("zone", userZone). + Msg("Read access not found") return false, nil } @@ -242,62 +248,10 @@ func isPublicReadable(logger zerolog.Logger, filesystem *fs.FileSystem, // getFileRange serves a file from iRODS to the client. It delegates to http.ServeContent // which sets the appropriate headers, including Content-Type. func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request, - account *types.IRODSAccount, rodsPath string) { - - // TODO: filesystem is thread safe, so it can be shared across requests - var rodsFs *fs.FileSystem + rodsFs *ifs.FileSystem, rodsPath string) { var err error - if rodsFs, err = fs.NewFileSystemWithDefault(account, AppName); err != nil { - logger.Err(err).Msg("Failed to create an iRODS file system") - writeErrorResponse(logger, w, http.StatusInternalServerError) - return - } - - defer rodsFs.Release() - - // Don't use filesystem.ExistsFile(objPath) here because it will return false if the - // file _does_ exist on the iRODS server, but the server is down or unreachable. - // - // filesystem.StatFile(objPath) is better because we can check for the error type. - if _, err = rodsFs.StatFile(rodsPath); err != nil { - if types.IsAuthError(err) { - logger.Err(err). - Str("path", rodsPath). - Msg("Failed to authenticate with iRODS") - writeErrorResponse(logger, w, http.StatusUnauthorized) - return - } - if types.IsFileNotFoundError(err) { - logger.Info(). - Str("path", rodsPath). - Msg("Requested path does not exist") - writeErrorResponse(logger, w, http.StatusNotFound) - return - } - logger.Err(err).Str("path", rodsPath).Msg("Failed to stat file") - writeErrorResponse(logger, w, http.StatusInternalServerError) - return - } - - zone := account.ClientZone - - var publicReadable bool - if publicReadable, err = isPublicReadable(logger, rodsFs, zone, rodsPath); err != nil { - logger.Err(err). - Str("path", rodsPath). - Msg("Failed to check public read access") - writeErrorResponse(logger, w, http.StatusInternalServerError) - return - } - if !publicReadable { - logger.Info(). - Str("path", rodsPath). - Msg("Requested path is not public readable") - writeErrorResponse(logger, w, http.StatusForbidden) - return - } - var fh *fs.FileHandle + var fh *ifs.FileHandle if fh, err = rodsFs.OpenFile(rodsPath, "", "r"); err != nil { logger.Err(err). Str("path", rodsPath). @@ -306,8 +260,8 @@ func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request, return } - defer func(fh *fs.FileHandle) { - if err = fh.Close(); err != nil { + defer func(fh *ifs.FileHandle) { + if err := fh.Close(); err != nil { logger.Err(err). Str("path", rodsPath). Msg("Failed to close file handle") @@ -325,10 +279,10 @@ func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request, // findItems runs a metadata query against iRODS to find any items that have metadata // with the key sqyrrl::index and value 1. The items are grouped by the value of the // metadata. -func findItems(filesystem *fs.FileSystem) (items []Item, err error) { // NRV +func findItems(filesystem *ifs.FileSystem) (items []Item, err error) { // NRV filesystem.ClearCache() // Clears all caches (entries, metadata, ACLs) - var entries []*fs.Entry + var entries []*ifs.Entry if entries, err = filesystem.SearchByMeta(IndexAttr, IndexValue); err != nil { return nil, err } diff --git a/server/routes.go b/server/routes.go index fbab553..201272a 100644 --- a/server/routes.go +++ b/server/routes.go @@ -28,41 +28,51 @@ const ( const ( EndpointRoot = "/" - EndPointStatic = EndpointRoot + "static/" + EndpointStatic = EndpointRoot + "static/" EndpointAPI = EndpointRoot + "api/v1/" - EndPointLogin = EndpointAPI + "login/" - EndPointLogout = EndpointAPI + "logout/" + EndpointLogin = EndpointAPI + "login/" + EndpointLogout = EndpointAPI + "logout/" EndpointAuthCallback = EndpointAPI + "auth-callback/" - EndPointIRODS = EndpointAPI + "irods/" + EndpointIRODS = EndpointAPI + "irods/" ) func (server *SqyrrlServer) addRoutes(mux *http.ServeMux) { + sm := server.sessionManager + correlate := AddCorrelationID(server) logRequest := AddRequestLogger(server) sanitiseURL := SanitiseRequestURL(server) - getStatic := http.StripPrefix(EndPointStatic, HandleStaticContent(server)) - getObject := http.StripPrefix(EndpointAPI, HandleIRODSGet(server)) + getStatic := http.StripPrefix(EndpointStatic, HandleStaticContent(server)) + getObject := http.StripPrefix(EndpointIRODS, HandleIRODSGet(server)) + + loginHandler := sm.LoadAndSave(correlate(logRequest(HandleLogin(server)))) + server.addRoute(mux, "GET", EndpointLogin, loginHandler) + + logoutHandler := sm.LoadAndSave(correlate(logRequest(HandleLogout(server)))) + server.addRoute(mux, "POST", EndpointLogout, logoutHandler) - mux.Handle("POST "+EndPointLogin, - correlate(logRequest(HandleLogin(server)))) - mux.Handle("POST "+EndPointLogout, - correlate(logRequest(HandleLogout(server)))) - mux.Handle("GET "+EndpointAuthCallback, - correlate(logRequest(HandleAuthCallback(server)))) + authCallbackHandler := sm.LoadAndSave(correlate(logRequest(HandleAuthCallback(server)))) + server.addRoute(mux, "GET", EndpointAuthCallback, authCallbackHandler) // The static endpoint is used to serve static files from a filesystem embedded in // the binary - mux.Handle("GET "+EndPointStatic, - sanitiseURL(correlate(logRequest(getStatic)))) + staticHandler := sm.LoadAndSave(sanitiseURL(correlate(logRequest(getStatic)))) + server.addRoute(mux, "GET", EndpointStatic, staticHandler) // The endpoint used to access files in iRODS - mux.Handle(EndPointIRODS, - sanitiseURL(correlate(logRequest(getObject)))) + irodsGetHandler := sm.LoadAndSave(sanitiseURL(correlate(logRequest(getObject)))) + server.addRoute(mux, "GET", EndpointIRODS, irodsGetHandler) // The root endpoint hosts a home page. Any requests relative to it are redirected // to the API endpoint - mux.Handle(EndpointRoot, - sanitiseURL(correlate(logRequest(HandleHomePage(server))))) + rootHandler := sm.LoadAndSave(sanitiseURL(correlate(logRequest(HandleHomePage(server))))) + server.addRoute(mux, "GET", EndpointRoot, rootHandler) +} + +func (server *SqyrrlServer) addRoute(mux *http.ServeMux, method string, endpoint string, + handler http.Handler) { + mux.Handle(method+" "+endpoint, handler) + server.handlers[endpoint] = handler } diff --git a/server/server.go b/server/server.go index 80c229c..019ef36 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "html/template" "net" "net/http" + "net/mail" "os" "os/signal" "path/filepath" @@ -54,6 +55,7 @@ type SqyrrlServer struct { oidcConfig *oidc.Config oidcProvider *oidc.Provider sessionManager *scs.SessionManager + handlers map[string]http.Handler // The HTTP handlers, to simplify testing context context.Context // Context for clean shutdown cancel context.CancelFunc // Cancel function for the server logger zerolog.Logger // Base logger from which the server creates its own sub-loggers @@ -229,7 +231,8 @@ func NewSqyrrlServer(logger zerolog.Logger, config Config) (server *SqyrrlServer http.Server{ Addr: addr, // Wrap the handler to enable automatic session management by scs - Handler: sessionManager.LoadAndSave(mux), + // Handler: sessionManager.LoadAndSave(mux), + Handler: mux, BaseContext: func(listener net.Listener) context.Context { return serverCtx }}, @@ -238,6 +241,7 @@ func NewSqyrrlServer(logger zerolog.Logger, config Config) (server *SqyrrlServer oidcConfig, oidcProvider, sessionManager, + make(map[string]http.Handler), serverCtx, cancelServer, subLogger, @@ -275,6 +279,16 @@ func (server *SqyrrlServer) IRODSAuthFilePath() string { return server.iRODSEnvManager.GetPasswordFilePath() } +// GetHandler returns the handler for the named endpoint. This is used for ease of testing +// because it will return a handler configured with the server's session manager. +func (server *SqyrrlServer) GetHandler(endpoint string) (http.Handler, error) { + // If the named handler is not in the handlers map, return an error + if handler, ok := server.handlers[endpoint]; ok { + return handler, nil + } + return nil, fmt.Errorf("no handler found for endpoint %s", endpoint) +} + // Start starts the server. This function blocks until the server is stopped. // // To stop the server, send SIGINT or SIGTERM to the process or call the server's Stop @@ -452,13 +466,11 @@ func ConfigureAndStart(logger zerolog.Logger, config Config) error { return fmt.Errorf("server sqyrrlConfig %w: index interval", ErrMissingArgument) } - var server *SqyrrlServer - server, err := NewSqyrrlServer(logger, config) - if err != nil { + if server, err := NewSqyrrlServer(logger, config); err != nil { return err + } else { + return server.Start() } - - return server.Start() } func getEnv(envVar string) (string, error) { @@ -470,6 +482,19 @@ func getEnv(envVar string) (string, error) { return val, nil } +// iRODSUserIDFromEmail extracts an iRODS user ID from an email address. This assumes +// that the email address is in the form "username@domain", which is the case for +// Sanger users authenticated via OpenID Connect. If the email address cannot be parsed, +// an empty string is returned. +func iRODSUserIDFromEmail(logger zerolog.Logger, email string) string { + address, err := mail.ParseAddress(email) + if err != nil { + logger.Err(err).Msg("Failed to parse user email address") + return "" + } + return strings.Split(address.Address, "@")[0] +} + func writeErrorResponse(logger zerolog.Logger, w http.ResponseWriter, code int, message ...string) { var msg string