diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..9e2412b --- /dev/null +++ b/auth.go @@ -0,0 +1,135 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "changkun.de/x/login" + "changkun.de/x/redir/internal/config" + "changkun.de/x/redir/internal/utils" +) + +var errUnauthorized = errors.New("request unauthorized") + +// blocklist holds the ip that should be blocked for further requests. +// +// This map may keep grow without releasing memory because of +// continuously attempts. we also do not persist this type of block info +// to the disk, which means if we reboot the service then all the blocker +// are gone and they can attack the server again. +// We clear the map very month. +var blocklist sync.Map // map[string]*blockinfo{} + +func init() { + t := time.NewTicker(time.Hour * 24 * 30) + go func() { + for range t.C { + blocklist.Range(func(k, v interface{}) bool { + blocklist.Delete(k) + return true + }) + } + }() +} + +type blockinfo struct { + failCount int64 + lastFail atomic.Value // time.Time + blockTime atomic.Value // time.Duration +} + +const maxFailureAttempts = 3 + +func (s *server) handleAuth(w http.ResponseWriter, r *http.Request) (user string, err error) { + switch config.Conf.Auth.Enable { + case config.None: + return + case config.SSO: + user, err := login.HandleAuth(w, r) + if err != nil { + uu, _ := url.Parse(config.Conf.Auth.SSO) + q := uu.Query() + q.Set("redirect", "https://"+r.Host+r.URL.String()) + uu.RawQuery = q.Encode() + http.Redirect(w, r, uu.String(), http.StatusFound) + } + return user, err + case config.Basic: + } + + w.Header().Set("WWW-Authenticate", `Basic realm="redir"`) + + u, p, ok := r.BasicAuth() + if !ok { + w.WriteHeader(http.StatusUnauthorized) + err = fmt.Errorf("%w: failed to parsing basic auth", errUnauthorized) + return + } + + // check if the IP failure attempts are too much + // if so, direct abort the request without checking credentials + ip := utils.ReadIP(r) + if i, ok := blocklist.Load(ip); ok { + info := i.(*blockinfo) + count := atomic.LoadInt64(&info.failCount) + if count > maxFailureAttempts { + // if the ip is under block, then directly abort + last := info.lastFail.Load().(time.Time) + bloc := info.blockTime.Load().(time.Duration) + + if time.Now().UTC().Sub(last.Add(bloc)) < 0 { + log.Printf("block ip %v, too much failure attempts. Block time: %v, release until: %v\n", + ip, bloc, last.Add(bloc)) + err = fmt.Errorf("%w: too much failure attempts", errUnauthorized) + return + } + + // clear the failcount, but increase the next block time + atomic.StoreInt64(&info.failCount, 0) + info.blockTime.Store(bloc * 2) + } + } + + defer func() { + if !errors.Is(err, errUnauthorized) { + return + } + + if i, ok := blocklist.Load(ip); !ok { + info := &blockinfo{ + failCount: 1, + } + info.lastFail.Store(time.Now().UTC()) + info.blockTime.Store(time.Second * 10) + + blocklist.Store(ip, info) + } else { + info := i.(*blockinfo) + atomic.AddInt64(&info.failCount, 1) + info.lastFail.Store(time.Now().UTC()) + } + }() + + found := false + for _, account := range config.Conf.Auth.Basic { + if u == account.Username && p == account.Password { + found = true + break + } + } + if !found { + w.WriteHeader(http.StatusUnauthorized) + return "", fmt.Errorf("%w: username or password is invalid", errUnauthorized) + } + return u, nil +} diff --git a/data/redirconf.yml b/data/redirconf.yml index afe6e7d..41752a8 100644 --- a/data/redirconf.yml +++ b/data/redirconf.yml @@ -21,8 +21,9 @@ x: repo_path: https://github.com/changkun godoc_host: https://pkg.go.dev/ auth: - enable: true - accounts: + enable: basic # or sso, none + sso: https://login.changkun.de + basic: - username: changkun password: redir stats: diff --git a/go.mod b/go.mod index 056e7e8..1d7f6c1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module changkun.de/x/redir go 1.16 require ( + changkun.de/x/login v0.0.0-20211122130521-1ad63a31a4e7 github.com/yuin/goldmark v1.4.2 go.mongodb.org/mongo-driver v1.5.1 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 diff --git a/go.sum b/go.sum index 6580586..208aace 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +changkun.de/x/login v0.0.0-20211122130521-1ad63a31a4e7 h1:nzxKIYUZF08AgXPqZMqGTnpZIWYaIkvk2F6nnxVQ4VA= +changkun.de/x/login v0.0.0-20211122130521-1ad63a31a4e7/go.mod h1:sxQtRW27EJgQr9R/SFKtyLppBbEOS8Fk0MXA1ptakEw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk= github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= @@ -31,6 +33,7 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= @@ -88,6 +91,7 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.4.2 h1:5qVKCqCRBaGz8EepBTi7pbIw8gGCFnB1Mi6kXU4dYv8= github.com/yuin/goldmark v1.4.2/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.mongodb.org/mongo-driver v1.5.1 h1:9nOVLGDfOaZ9R0tBumx/BcuqkbFpyTCU2r/Po7A2azI= go.mongodb.org/mongo-driver v1.5.1/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS4xxMmUqw= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -110,6 +114,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/internal/config/config.go b/internal/config/config.go index 6f9e145..a4ccc1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,14 @@ import ( "gopkg.in/yaml.v3" ) +type authType string + +var ( + None authType = "none" + Basic authType = "basic" + SSO authType = "sso" +) + type config struct { Title string `yaml:"title"` Host string `yaml:"host"` @@ -41,11 +49,12 @@ type config struct { GoDocHost string `yaml:"godoc_host"` } `yaml:"x"` Auth struct { - Enable bool `yaml:"enable"` - Accounts []struct { + Enable authType `yaml:"enable"` + SSO string `yaml:"sso"` + Basic []struct { Username string `yaml:"username"` Password string `yaml:"password"` - } `yaml:"accounts"` + } `yaml:"basic"` } `yaml:"auth"` Stats struct { Enable bool `yaml:"enable"` diff --git a/internal/config/config.yml b/internal/config/config.yml index 5d4dc52..13429b1 100644 --- a/internal/config/config.yml +++ b/internal/config/config.yml @@ -21,8 +21,9 @@ x: repo_path: https://github.com/changkun godoc_host: https://pkg.go.dev/ auth: - enable: true - accounts: + enable: basic # or sso, none + sso: https://login.changkun.de + basic: - username: changkun password: redir stats: diff --git a/short.go b/short.go index 6f10bdb..133c825 100644 --- a/short.go +++ b/short.go @@ -17,8 +17,6 @@ import ( "path/filepath" "strconv" "strings" - "sync" - "sync/atomic" "time" "changkun.de/x/redir/internal/config" @@ -27,8 +25,6 @@ import ( "changkun.de/x/redir/internal/utils" ) -var errUnauthorized = errors.New("request unauthorized") - // shortHandler redirects the current request to a known link if the alias is // found in the redir store. func (s *server) shortHandler(kind models.AliasKind) http.Handler { @@ -59,107 +55,6 @@ func (s *server) shortHandler(kind models.AliasKind) http.Handler { }) } -// blocklist holds the ip that should be blocked for further requests. -// -// This map may keep grow without releasing memory because of -// continuously attempts. we also do not persist this type of block info -// to the disk, which means if we reboot the service then all the blocker -// are gone and they can attack the server again. -// We clear the map very month. -var blocklist sync.Map // map[string]*blockinfo{} - -func init() { - t := time.NewTicker(time.Hour * 24 * 30) - go func() { - for range t.C { - blocklist.Range(func(k, v interface{}) bool { - blocklist.Delete(k) - return true - }) - } - }() -} - -type blockinfo struct { - failCount int64 - lastFail atomic.Value // time.Time - blockTime atomic.Value // time.Duration -} - -const maxFailureAttempts = 3 - -func (s *server) handleAuth(w http.ResponseWriter, r *http.Request) (user, pass string, err error) { - if !config.Conf.Auth.Enable { - return - } - - w.Header().Set("WWW-Authenticate", `Basic realm="redir"`) - - u, p, ok := r.BasicAuth() - if !ok { - w.WriteHeader(http.StatusUnauthorized) - err = fmt.Errorf("%w: failed to parsing basic auth", errUnauthorized) - return - } - - // check if the IP failure attempts are too much - // if so, direct abort the request without checking credentials - ip := utils.ReadIP(r) - if i, ok := blocklist.Load(ip); ok { - info := i.(*blockinfo) - count := atomic.LoadInt64(&info.failCount) - if count > maxFailureAttempts { - // if the ip is under block, then directly abort - last := info.lastFail.Load().(time.Time) - bloc := info.blockTime.Load().(time.Duration) - - if time.Now().UTC().Sub(last.Add(bloc)) < 0 { - log.Printf("block ip %v, too much failure attempts. Block time: %v, release until: %v\n", - ip, bloc, last.Add(bloc)) - err = fmt.Errorf("%w: too much failure attempts", errUnauthorized) - return - } - - // clear the failcount, but increase the next block time - atomic.StoreInt64(&info.failCount, 0) - info.blockTime.Store(bloc * 2) - } - } - - defer func() { - if !errors.Is(err, errUnauthorized) { - return - } - - if i, ok := blocklist.Load(ip); !ok { - info := &blockinfo{ - failCount: 1, - } - info.lastFail.Store(time.Now().UTC()) - info.blockTime.Store(time.Second * 10) - - blocklist.Store(ip, info) - } else { - info := i.(*blockinfo) - atomic.AddInt64(&info.failCount, 1) - info.lastFail.Store(time.Now().UTC()) - } - }() - - found := false - for _, account := range config.Conf.Auth.Accounts { - if u == account.Username && p == account.Password { - found = true - break - } - } - if !found { - w.WriteHeader(http.StatusUnauthorized) - return "", "", fmt.Errorf("%w: username or password is invalid", errUnauthorized) - } - return u, p, nil -} - type shortInput struct { Op short.Op `json:"op"` Alias string `json:"alias"` @@ -186,7 +81,7 @@ func (s *server) shortHandlerPost(kind models.AliasKind, w http.ResponseWriter, }() // All post request must be authenticated. - user, _, err := s.handleAuth(w, r) + user, err := s.handleAuth(w, r) if err != nil { return } @@ -568,7 +463,7 @@ func (s *server) sIndex( case "index-pro": // data with statistics return s.indexData(ctx, w, r, kind, false) case "admin": - _, _, err := s.handleAuth(w, r) + _, err := s.handleAuth(w, r) if err != nil { return err } @@ -600,7 +495,7 @@ func (s *server) indexData( public bool, ) error { if !public { - _, _, err := s.handleAuth(w, r) + _, err := s.handleAuth(w, r) if err != nil { return err } diff --git a/vendor/changkun.de/x/login/.gitignore b/vendor/changkun.de/x/login/.gitignore new file mode 100644 index 0000000..66fd13c --- /dev/null +++ b/vendor/changkun.de/x/login/.gitignore @@ -0,0 +1,15 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/vendor/changkun.de/x/login/LICENSE b/vendor/changkun.de/x/login/LICENSE new file mode 100644 index 0000000..6175743 --- /dev/null +++ b/vendor/changkun.de/x/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Changkun Ou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/changkun.de/x/login/Makefile b/vendor/changkun.de/x/login/Makefile new file mode 100644 index 0000000..bdb4bd6 --- /dev/null +++ b/vendor/changkun.de/x/login/Makefile @@ -0,0 +1,24 @@ +# Copyright (c) 2021 Changkun Ou . All Rights Reserved. +# Unauthorized using, copying, modifying and distributing, via any +# medium is strictly prohibited. + +NAME = login +BUILD_FLAGS = -o $(NAME) + +all: + go build $(BUILD_FLAGS) cmd/login/*.go +run: + ./$(NAME) +initdb: + cd db && go run initdb.go +build: + CGO_ENABLED=0 GOOS=linux go build $(BUILD_FLAGS) cmd/login/*.go + docker build -f docker/Dockerfile -t $(NAME):latest . +up: + docker-compose -f docker/docker-compose.yml up -d +down: + docker-compose -f docker/docker-compose.yml down +clean: + rm -rf $(NAME) + docker rmi -f $(shell docker images -f "dangling=true" -q) 2> /dev/null; true + docker rmi -f $(NAME):latest 2> /dev/null; true \ No newline at end of file diff --git a/vendor/changkun.de/x/login/README.md b/vendor/changkun.de/x/login/README.md new file mode 100644 index 0000000..4532540 --- /dev/null +++ b/vendor/changkun.de/x/login/README.md @@ -0,0 +1,22 @@ +# login + +Lightweight SSO Login System + +## Convention + +1. Redirect to `login.changkun.de?redirect=origin` +2. When login success, `login.changkun.de` will redirect to origin with query parameter `token=xxx` +3. A service provider should do: + 1. Post the token to `login.changkun.de/verify` to verify if the token is a valid token or not. If the token is not valid, do nothing. + 2. If the token is valid, then authentication success, and set cookie to the user. + 3. Later request to the service provider will have the cookie with the token, each time should verify the token is valid or not internally. If valid, authorize success. If not, redirect to `login.changkun.de?redir=origin`. + +## Test page + +`/test` + +## License + +Copyright (c) 2021 Changkun Ou. All Rights Reserved. +Unauthorized using, copying, modifying and distributing,via any medium +is strictly prohibited. \ No newline at end of file diff --git a/vendor/changkun.de/x/login/go.mod b/vendor/changkun.de/x/login/go.mod new file mode 100644 index 0000000..390f7a0 --- /dev/null +++ b/vendor/changkun.de/x/login/go.mod @@ -0,0 +1,10 @@ +module changkun.de/x/login + +go 1.18 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible + go.etcd.io/bbolt v1.3.6 +) + +require golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d // indirect diff --git a/vendor/changkun.de/x/login/go.sum b/vendor/changkun.de/x/login/go.sum new file mode 100644 index 0000000..773449f --- /dev/null +++ b/vendor/changkun.de/x/login/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d h1:L/IKR6COd7ubZrs2oTnTi73IhgqJ71c9s80WsQnh0Es= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/vendor/changkun.de/x/login/login.go b/vendor/changkun.de/x/login/login.go new file mode 100644 index 0000000..430e553 --- /dev/null +++ b/vendor/changkun.de/x/login/login.go @@ -0,0 +1,106 @@ +// Copyright (c) 2021 Changkun Ou . All Rights Reserved. +// Unauthorized using, copying, modifying and distributing, via any +// medium is strictly prohibited. + +package login + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +var ( + // AuthEndpoint is the login authorization endpoint. + AuthEndpoint = "https://login.changkun.de/auth" + // VerifyEndpoint is the login verify endpoint. + VerifyEndpoint = "https://login.changkun.de/verify" +) + +var ( + ErrBadRequest = errors.New("bad request") + ErrUnauthorized = errors.New("unauthorized login") +) + +// Verify checks if the given login token is valid or not. +func Verify(token string) (string, error) { + b, _ := json.Marshal(struct { + Token string `json:"token"` + }{ + Token: token, + }) + br := bytes.NewReader(b) + + resp, err := http.DefaultClient.Post(VerifyEndpoint, "application/json", br) + if err != nil { + return "", ErrBadRequest + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", ErrUnauthorized + } + + b, err = io.ReadAll(resp.Body) + if err != nil { + return "", ErrBadRequest + } + + x := &struct { + User string `json:"username"` + }{} + err = json.Unmarshal(b, x) + if err != nil { + return "", ErrBadRequest + } + + return x.User, nil +} + +// Handle handles authentication by checking either query parameters +// regarding token or cookie auth. +func HandleAuth(w http.ResponseWriter, r *http.Request) (string, error) { + // 1st try: query parameter. + token := r.URL.Query().Get("token") + if token == "" { + // 2nd try: cookie. + c, err := r.Cookie("auth") + if err != nil { + return "", err + } + if c.Value == "" { + return "", ErrUnauthorized + } + + token = c.Value + } + + u, err := Verify(token) + if err == nil { + w.Header().Set("Set-Cookie", fmt.Sprintf("auth=%s; Max-Age=%d", token, 60*60*24*60)) // 3 months + } + return u, err +} + +// RequestToken requests the login endpoint and returns the token for login. +func RequestToken(user, pass string) (string, error) { + b, _ := json.Marshal(struct { + Username string `json:"username"` + Password string `json:"password"` + }{Username: user, Password: pass}) + br := bytes.NewReader(b) + + resp, err := http.DefaultClient.Post(AuthEndpoint, "application/json", br) + if err != nil { + return "", ErrBadRequest + } + defer resp.Body.Close() + + cookies := resp.Cookies() + if resp.StatusCode != http.StatusOK || len(cookies) == 0 { + return "", ErrUnauthorized + } + return cookies[0].Value, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 9e1d2eb..bfa64e9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,3 +1,6 @@ +# changkun.de/x/login v0.0.0-20211122130521-1ad63a31a4e7 +## explicit +changkun.de/x/login # github.com/aws/aws-sdk-go v1.34.28 github.com/aws/aws-sdk-go/aws github.com/aws/aws-sdk-go/aws/awserr