change cookie name
continuous-integration/drone/pr Build is passing
Details
continuous-integration/drone/pr Build is passing
Details
This commit is contained in:
parent
f03f54a3d0
commit
31f5f950dd
|
@ -158,9 +158,12 @@ def reset_user_password(username: str):
|
|||
|
||||
|
||||
@bp.route('/<username>', methods=['DELETE'])
|
||||
@requires_admin_creds
|
||||
@authz_restrict_to_syscom
|
||||
@development_only
|
||||
def delete_user(username: str):
|
||||
# We use the admin creds for the integration tests for the web app, which
|
||||
# uses the ceod/<host> key
|
||||
txn = DeleteMemberTransaction(username)
|
||||
return create_streaming_response(txn)
|
||||
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
/ceod-web
|
||||
/app.sock
|
||||
/test
|
||||
|
|
|
@ -35,6 +35,12 @@ You can change the `-u` (username) and `-f` (first name) arguments to simulate
|
|||
a different user. See the .drone/data.ldif file in the parent directory to see
|
||||
all mock users.
|
||||
|
||||
### Templates and static assets
|
||||
In development, the templated views and static assets will be loaded from the
|
||||
internal/views and internal/static folders, respectively. Unfortunately I think
|
||||
that the Echo framework caches those files internally so you will need to
|
||||
restart the app process if you modify them.
|
||||
|
||||
### Emails
|
||||
By default, no emails will be sent; they will only be printed to stdout. To
|
||||
send real emails, add these fields to the dev.json (replace `your_username`):
|
||||
|
@ -48,6 +54,14 @@ send real emails, add these fields to the dev.json (replace `your_username`):
|
|||
This will send all of the emails to your email address (the `To` header will
|
||||
still be preserved, however).
|
||||
|
||||
## Tests
|
||||
```sh
|
||||
# On the host
|
||||
CGO_ENABLED=0 go test -c -o test ./tests
|
||||
# In the container
|
||||
./test -test.v
|
||||
```
|
||||
|
||||
## Deployment
|
||||
Apache configuration on caffeine:
|
||||
```
|
||||
|
|
|
@ -3,12 +3,16 @@ module git.csclub.uwaterloo.ca/public/pyceo/web
|
|||
go 1.21.4
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
|
@ -18,6 +22,7 @@ require (
|
|||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
|
@ -25,4 +30,5 @@ require (
|
|||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
@ -56,6 +60,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
|||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
|
@ -65,6 +70,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -78,6 +84,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
|||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
|
@ -88,6 +95,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
@ -17,6 +17,8 @@ import (
|
|||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
type TemplateManager struct {
|
||||
|
@ -93,31 +95,42 @@ func newListener(sockPath string) net.Listener {
|
|||
return l
|
||||
}
|
||||
|
||||
func Start(cfg *config.Config) {
|
||||
func useFCGI(cfg *config.Config) bool {
|
||||
return !cfg.IsDev
|
||||
}
|
||||
|
||||
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
|
||||
e := echo.New()
|
||||
app := app.NewApp(cfg)
|
||||
app := app.NewApp(cfg, mailSrv)
|
||||
e.Logger.SetLevel(log.DEBUG)
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.RequestID())
|
||||
e.Use(helmet(cfg.IsDev))
|
||||
addStaticAssets(cfg.IsDev, e)
|
||||
useFCGI := !cfg.IsDev
|
||||
e.Use(appContextMiddleware(useFCGI, app))
|
||||
e.Use(appContextMiddleware(useFCGI(cfg), app))
|
||||
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "cookie:_csrf",
|
||||
// The cookie name needs to be very unique because this app is
|
||||
// being served from the root CSC domain
|
||||
TokenLookup: "cookie:ceod-web-csrf",
|
||||
CookieName: "ceod-web-csrf",
|
||||
CookiePath: cfg.GetAppPath(),
|
||||
CookieDomain: cfg.GetAppHostname(),
|
||||
CookieSecure: true,
|
||||
CookieSecure: !cfg.IsDev,
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
}))
|
||||
e.HTTPErrorHandler = httpErrorHandler
|
||||
e.Renderer = newTemplateManager(cfg.IsDev)
|
||||
addRoutes(e)
|
||||
return e
|
||||
}
|
||||
|
||||
func Start(cfg *config.Config) {
|
||||
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
|
||||
listener := newListener(cfg.SocketPath)
|
||||
e.Logger.Info("Listening on " + cfg.SocketPath)
|
||||
if useFCGI {
|
||||
if useFCGI(cfg) {
|
||||
e.Logger.Fatal(fcgi.Serve(listener, e))
|
||||
} else {
|
||||
e.Logger.Fatal(http.Serve(listener, e))
|
||||
|
|
|
@ -33,7 +33,7 @@ func renderHTTPErrorPage(c echo.Context, code int, name string) {
|
|||
if err := c.Render(code, name, data); err != nil {
|
||||
c.Logger().Error(err)
|
||||
// Fall back to plain text response
|
||||
c.String(http.StatusInternalServerError, "Internal server error")
|
||||
_ = c.String(http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ func renderAppErrorPage(c echo.Context, renderInfo *errorRenderInfo) {
|
|||
if err := c.Render(http.StatusOK, "app_error", renderInfo); err != nil {
|
||||
c.Logger().Error(err)
|
||||
// Fall back to plain text response
|
||||
c.String(http.StatusInternalServerError, "Internal server error")
|
||||
_ = c.String(http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -51,8 +51,8 @@ func helmet(isDev bool) echo.MiddlewareFunc {
|
|||
func getReqInfoFromFCGI(r *http.Request) (*app.ReqInfo, error) {
|
||||
env := fcgi.ProcessEnv(r)
|
||||
u := new(app.ReqInfo)
|
||||
u.Username, _ = env["MELLON_samaccountname"]
|
||||
u.GivenName, _ = env["MELLON_givenname"]
|
||||
u.Username = env["MELLON_samaccountname"]
|
||||
u.GivenName = env["MELLON_givenname"]
|
||||
if u.Username == "" {
|
||||
return nil, errors.New("FCGI environment is missing username")
|
||||
} else if u.GivenName == "" {
|
||||
|
@ -63,11 +63,11 @@ func getReqInfoFromFCGI(r *http.Request) (*app.ReqInfo, error) {
|
|||
|
||||
func getReqInfoFromHTTPHeaders(r *http.Request) (*app.ReqInfo, error) {
|
||||
// header names must be in canonical form (see http.CanonicalHeaderKey)
|
||||
usernames, _ := r.Header["X-Csc-Adfs-Username"]
|
||||
usernames := r.Header["X-Csc-Adfs-Username"]
|
||||
if len(usernames) == 0 {
|
||||
return nil, errors.New("Username is missing from HTTP headers")
|
||||
}
|
||||
givenNames, _ := r.Header["X-Csc-Adfs-Firstname"]
|
||||
givenNames := r.Header["X-Csc-Adfs-Firstname"]
|
||||
if len(givenNames) == 0 {
|
||||
return nil, errors.New("Given name is missing from HTTP headers")
|
||||
}
|
||||
|
|
|
@ -15,11 +15,11 @@ type App struct {
|
|||
mail service.MailService
|
||||
}
|
||||
|
||||
func NewApp(cfg *config.Config) *App {
|
||||
func NewApp(cfg *config.Config, mailSrv service.MailService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
ceod: service.NewCeodService(cfg),
|
||||
mail: service.NewMailService(cfg),
|
||||
mail: mailSrv,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,7 +33,7 @@ func (app *App) GetUser(ctx AppContext, username string) (*model.User, error) {
|
|||
}
|
||||
return nil, newAppError(ERROR_OTHER)
|
||||
}
|
||||
if cscUser.ShadowExpire != nil && *cscUser.ShadowExpire == 1 {
|
||||
if cscUser.ShadowExpire == 1 {
|
||||
return nil, newAppError(ERROR_MEMBERSHIP_EXPIRED)
|
||||
}
|
||||
return cscUser, nil
|
||||
|
|
|
@ -76,6 +76,8 @@ func NewConfig(cfgPath string) *Config {
|
|||
cfg.appPath = parsedURL.Path
|
||||
if cfg.appPath == "" {
|
||||
cfg.appPath = "/"
|
||||
} else if cfg.appPath[len(cfg.appPath)-1] != '/' {
|
||||
cfg.appPath += "/"
|
||||
}
|
||||
if !cfg.IsDev && cfg.MTA == "" {
|
||||
panic(fmt.Errorf(`"mta" is missing from %s`, cfgPath))
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
krb5client "github.com/jcmturner/gokrb5/v8/client"
|
||||
|
@ -11,7 +14,6 @@ import (
|
|||
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
|
@ -50,49 +52,144 @@ type PwresetResponse struct {
|
|||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (c *Ceod) request(ctx logging.ContextWithLogger, method, urlPath string, respBody any) error {
|
||||
func (c *Ceod) getResp(ctx model.CeodRequestContext, method, urlPath string, body any) (*http.Response, error) {
|
||||
ctx.Log().Debugf("%s %s", method, urlPath)
|
||||
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, nil)
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewBuffer(buf)
|
||||
}
|
||||
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not build HTTP request: %w", err)
|
||||
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.spnegoClient.Do(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SPNEGO request failed: %w", err)
|
||||
return nil, fmt.Errorf("SPNEGO request failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getRespError(ctx model.CeodRequestContext, resp *http.Response) error {
|
||||
if resp.StatusCode/100 == 2 {
|
||||
return nil
|
||||
}
|
||||
rawBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"HTTP status=%d, and failed to read body: %w",
|
||||
resp.StatusCode, err,
|
||||
)
|
||||
}
|
||||
var errBody ErrorResponse
|
||||
err = json.Unmarshal(rawBody, &errBody)
|
||||
var message string
|
||||
if err != nil {
|
||||
message = string(rawBody)
|
||||
} else {
|
||||
message = errBody.Error
|
||||
}
|
||||
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
|
||||
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
|
||||
}
|
||||
|
||||
func (c *Ceod) request(ctx model.CeodRequestContext, method, urlPath string, respBody any) error {
|
||||
resp, err := c.getResp(ctx, method, urlPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
var errBody ErrorResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&errBody)
|
||||
var message string
|
||||
if err != nil {
|
||||
message = "[invalid JSON response]"
|
||||
} else {
|
||||
message = errBody.Error
|
||||
}
|
||||
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
|
||||
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
|
||||
err = getRespError(ctx, resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
err = json.NewDecoder(resp.Body).Decode(respBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to decode JSON response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Ceod) GetUser(ctx logging.ContextWithLogger, username string) (*model.User, error) {
|
||||
func parseTransactionMessage[T any](b []byte) (model.ITransactionMessage, error) {
|
||||
var txn model.TransactionMessage
|
||||
if err := json.Unmarshal(b, &txn); err != nil {
|
||||
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
|
||||
}
|
||||
var itxn model.ITransactionMessage
|
||||
switch txn.Status {
|
||||
case "in progress":
|
||||
itxn = new(model.TransactionInProgress)
|
||||
case "completed":
|
||||
itxn = new(model.TransactionCompleted[T])
|
||||
case "aborted":
|
||||
itxn = new(model.TransactionAborted)
|
||||
default:
|
||||
return nil, fmt.Errorf("Unrecognized transaction status '%s'", txn.Status)
|
||||
}
|
||||
if err := json.Unmarshal(b, itxn); err != nil {
|
||||
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
|
||||
}
|
||||
return itxn, nil
|
||||
}
|
||||
|
||||
func requestTransaction[T any](
|
||||
c *Ceod, ctx model.CeodTransactionRequestContext,
|
||||
method, urlPath string, body any,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
resp, err := c.getResp(ctx, method, urlPath, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = getRespError(ctx, resp)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
txnChan := make(chan model.ITransactionMessage)
|
||||
go func() {
|
||||
defer resp.Body.Close()
|
||||
defer close(txnChan)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
msg, err := parseTransactionMessage[T](scanner.Bytes())
|
||||
if err != nil {
|
||||
ctx.Log().Error(err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case txnChan <- msg:
|
||||
continue
|
||||
case <-ctx.CancelChan():
|
||||
ctx.Log().Warn("Cancel chan closed, returning")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
ctx.Log().Error(err)
|
||||
}
|
||||
}()
|
||||
return txnChan, nil
|
||||
}
|
||||
|
||||
func (c *Ceod) GetUser(ctx model.CeodRequestContext, username string) (*model.User, error) {
|
||||
user := &model.User{}
|
||||
err := c.request(ctx, "GET", "/api/members/"+username, user)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (c *Ceod) GetUWUser(ctx logging.ContextWithLogger, username string) (*model.UWUser, error) {
|
||||
func (c *Ceod) GetUWUser(ctx model.CeodRequestContext, username string) (*model.UWUser, error) {
|
||||
uwUser := &model.UWUser{}
|
||||
err := c.request(ctx, "GET", "/api/uwldap/"+username, uwUser)
|
||||
return uwUser, err
|
||||
}
|
||||
|
||||
func (c *Ceod) Pwreset(ctx logging.ContextWithLogger, username string) (string, error) {
|
||||
func (c *Ceod) Pwreset(ctx model.CeodRequestContext, username string) (string, error) {
|
||||
resp := &PwresetResponse{}
|
||||
err := c.request(ctx, "POST", "/api/members/"+username+"/pwreset", resp)
|
||||
if err != nil {
|
||||
|
@ -100,3 +197,17 @@ func (c *Ceod) Pwreset(ctx logging.ContextWithLogger, username string) (string,
|
|||
}
|
||||
return resp.Password, nil
|
||||
}
|
||||
|
||||
func (c *Ceod) AddUser(
|
||||
ctx model.CeodTransactionRequestContext,
|
||||
req *model.AddUserRequest,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
return requestTransaction[model.UserWithPassword](c, ctx, "POST", "/api/members", req)
|
||||
}
|
||||
|
||||
func (c *Ceod) DeleteUser(
|
||||
ctx model.CeodTransactionRequestContext,
|
||||
username string,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
return requestTransaction[string](c, ctx, "DELETE", "/api/members/"+username, nil)
|
||||
}
|
||||
|
|
|
@ -21,17 +21,17 @@ type MailService interface {
|
|||
) error
|
||||
}
|
||||
|
||||
type mailBase struct {
|
||||
cfg *config.Config
|
||||
type MailBackend interface {
|
||||
Send(addr string, from string, to []string, msg []byte) error
|
||||
}
|
||||
|
||||
type Mail struct {
|
||||
mailBase
|
||||
type mail struct {
|
||||
cfg *config.Config
|
||||
backend MailBackend
|
||||
}
|
||||
|
||||
type MockMail struct {
|
||||
mailBase
|
||||
}
|
||||
type prodMailBackend struct{}
|
||||
type devMailBackend struct{}
|
||||
|
||||
//go:embed pwreset_email.txt
|
||||
var emailTemplateFiles embed.FS
|
||||
|
@ -56,18 +56,25 @@ func init() {
|
|||
}
|
||||
}
|
||||
|
||||
func NewMailService(cfg *config.Config) MailService {
|
||||
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
|
||||
return &MockMail{mailBase{cfg: cfg}}
|
||||
}
|
||||
return &Mail{mailBase{cfg: cfg}}
|
||||
func NewMailServiceWithBackend(cfg *config.Config, backend MailBackend) MailService {
|
||||
return &mail{cfg, backend}
|
||||
}
|
||||
|
||||
func (m *mailBase) senderAddress() string {
|
||||
func NewMailService(cfg *config.Config) MailService {
|
||||
var backend MailBackend
|
||||
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
|
||||
backend = devMailBackend{}
|
||||
} else {
|
||||
backend = prodMailBackend{}
|
||||
}
|
||||
return NewMailServiceWithBackend(cfg, backend)
|
||||
}
|
||||
|
||||
func (m *mail) senderAddress() string {
|
||||
return "ceod+web@" + m.cfg.CSCDomain
|
||||
}
|
||||
|
||||
func (m *mailBase) render(
|
||||
func (m *mail) render(
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
) ([]byte, error) {
|
||||
|
@ -88,7 +95,7 @@ func (m *mailBase) render(
|
|||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *Mail) Send(
|
||||
func (m *mail) Send(
|
||||
ctx logging.ContextWithLogger,
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
|
@ -103,25 +110,20 @@ func (m *Mail) Send(
|
|||
if m.cfg.ForcedEmailRecipient != "" {
|
||||
realRecipientAddress = m.cfg.ForcedEmailRecipient
|
||||
}
|
||||
return m.backend.Send(m.cfg.MTA, m.senderAddress(), []string{realRecipientAddress}, msg)
|
||||
}
|
||||
|
||||
func (m devMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
fmt.Printf("Would have sent this email:\n%s", string(msg))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m prodMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
return smtp.SendMail(
|
||||
m.cfg.MTA,
|
||||
addr,
|
||||
nil, // auth
|
||||
m.senderAddress(),
|
||||
[]string{realRecipientAddress},
|
||||
from,
|
||||
to,
|
||||
msg,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *MockMail) Send(
|
||||
ctx logging.ContextWithLogger,
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
) error {
|
||||
msg, err := m.render(recipientName, recipientAddress, tmplName, data)
|
||||
if err != nil {
|
||||
ctx.Log().Error(err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Would have sent this email:\n%s", msg)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -8,4 +8,7 @@
|
|||
our emails there. If you still have not received the confirmation email after
|
||||
20 minutes, please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="..">Click here to return home</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
|
|
|
@ -13,10 +13,36 @@ func (e *CeodError) Error() string {
|
|||
return e.Message
|
||||
}
|
||||
|
||||
type CeodRequestContext logging.ContextWithLogger
|
||||
|
||||
type CeodTransactionRequestContext interface {
|
||||
logging.ContextWithLogger
|
||||
CancelChan() <-chan struct{}
|
||||
}
|
||||
|
||||
type CeodService interface {
|
||||
GetUser(ctx logging.ContextWithLogger, username string) (*User, error)
|
||||
GetUWUser(ctx logging.ContextWithLogger, username string) (*UWUser, error)
|
||||
Pwreset(ctx logging.ContextWithLogger, username string) (string, error)
|
||||
GetUser(ctx CeodRequestContext, username string) (*User, error)
|
||||
GetUWUser(ctx CeodRequestContext, username string) (*UWUser, error)
|
||||
Pwreset(ctx CeodRequestContext, username string) (string, error)
|
||||
// On success, last message will be TransactionCompleted[UserWithPassword]
|
||||
AddUser(ctx CeodTransactionRequestContext, req *AddUserRequest) (<-chan ITransactionMessage, error)
|
||||
// On success, last message will be string "OK"
|
||||
DeleteUser(ctx CeodTransactionRequestContext, username string) (<-chan ITransactionMessage, error)
|
||||
}
|
||||
|
||||
type AddUserRequest struct {
|
||||
// Full name
|
||||
Cn string `json:"cn"`
|
||||
// Last name
|
||||
Sn string `json:"sn"`
|
||||
// First name
|
||||
GivenName string `json:"given_name"`
|
||||
// Username
|
||||
Uid string `json:"uid"`
|
||||
Program string `json:"program"`
|
||||
Terms int `json:"terms,omitempty"`
|
||||
NonMemberTerms int `json:"non_member_terms,omitempty"`
|
||||
ForwardingAddresses []string `json:"forwarding_addresses"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
@ -35,19 +61,19 @@ type User struct {
|
|||
Groups []string `json:"groups"`
|
||||
Program string `json:"program"`
|
||||
// Terms will be absent for club reps
|
||||
Terms []string `json:"terms"`
|
||||
Terms []string `json:"terms,omitempty"`
|
||||
// NonMemberTerms will be absent for general members
|
||||
NonMemberTerms []string `json:"non_member_terms"`
|
||||
NonMemberTerms []string `json:"non_member_terms,omitempty"`
|
||||
MailLocalAddresses []string `json:"mail_local_addresses"`
|
||||
// ForwardingAddresses will be absent if the client does not have
|
||||
// sufficient permissions. It will be empty if the client's
|
||||
// ~/.forward is missing or empty.
|
||||
ForwardingAddresses []string `json:"forwarding_addresses"`
|
||||
ForwardingAddresses []string `json:"forwarding_addresses,omitempty"`
|
||||
IsClub bool `json:"is_club"`
|
||||
IsClubRep bool `json:"is_club_rep"`
|
||||
// ShadowExpire will be 1 if the user's account is expired, and
|
||||
// absent otherwise.
|
||||
ShadowExpire *int `json:"shadow_expire"`
|
||||
ShadowExpire int `json:"shadow_expire,omitempty"`
|
||||
}
|
||||
|
||||
type UWUser struct {
|
||||
|
@ -55,9 +81,35 @@ type UWUser struct {
|
|||
Uid string `json:"uid"`
|
||||
MailLocalAddresses []string `json:"mail_local_addresses"`
|
||||
// The following fields might be absent for alumni
|
||||
Cn *string `json:"cn"`
|
||||
GivenName *string `json:"given_name"`
|
||||
Sn *string `json:"sn"`
|
||||
Cn string `json:"cn,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
Sn string `json:"sn,omitempty"`
|
||||
// This field will always be absent for alumni
|
||||
Program *string `json:"program"`
|
||||
Program string `json:"program,omitempty"`
|
||||
}
|
||||
|
||||
type ITransactionMessage interface{}
|
||||
|
||||
type TransactionMessage struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TransactionInProgress struct {
|
||||
TransactionMessage // "in progress"
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
type TransactionAborted struct {
|
||||
TransactionMessage // "aborted"
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type TransactionCompleted[T any] struct {
|
||||
TransactionMessage // "completed"
|
||||
Result T `json:"result"`
|
||||
}
|
||||
|
||||
type UserWithPassword struct {
|
||||
User
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
|
|
@ -46,7 +46,10 @@ func (p *Proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
|
|||
wr.Header()[k] = v
|
||||
}
|
||||
wr.WriteHeader(resp.StatusCode)
|
||||
io.Copy(wr, resp.Body)
|
||||
_, err = io.Copy(wr, resp.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
// Make sure app_url is using "127.0.0.1", not "localhost", because that is
|
||||
// what httptest.Server uses, and the cookie domain needs to match exactly
|
||||
var Cfg = config.NewConfig("tests/test.json")
|
||||
var Ceod = service.NewCeodService(Cfg)
|
||||
var TestMail = TestMailBackend{}
|
||||
var TestServer = httptest.NewServer(api.NewAPI(
|
||||
Cfg, Ceod, service.NewMailServiceWithBackend(Cfg, &TestMail),
|
||||
))
|
||||
|
||||
type MockEmail struct {
|
||||
MTA string
|
||||
From string
|
||||
To []string
|
||||
Msg []byte
|
||||
}
|
||||
type TestMailBackend struct {
|
||||
SentEmails []MockEmail
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (m *TestMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
msg = bytes.ReplaceAll(msg, []byte("\r\n"), []byte("\n"))
|
||||
m.mutex.Lock()
|
||||
m.SentEmails = append(m.SentEmails, MockEmail{addr, from, to, msg})
|
||||
m.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (m *TestMailBackend) Reset() {
|
||||
m.SentEmails = nil
|
||||
}
|
||||
|
||||
var simpleUsernameCounter int
|
||||
var simpleUsernameMutex sync.Mutex
|
||||
|
||||
func getNextSimpleUsername() string {
|
||||
simpleUsernameMutex.Lock()
|
||||
simpleUsernameCounter += 1
|
||||
i := simpleUsernameCounter
|
||||
simpleUsernameMutex.Unlock()
|
||||
return fmt.Sprintf("ceodweb%d", i)
|
||||
}
|
||||
|
||||
type TestLogger struct{}
|
||||
|
||||
var SharedTestLogger TestLogger
|
||||
|
||||
func (t *TestLogger) Debug(i ...interface{}) {}
|
||||
func (t *TestLogger) Debugf(format string, args ...interface{}) {}
|
||||
func (t *TestLogger) Info(i ...interface{}) {}
|
||||
func (t *TestLogger) Infof(format string, args ...interface{}) {}
|
||||
func (t *TestLogger) Warn(i ...interface{}) {
|
||||
log.Println(i...)
|
||||
}
|
||||
func (t *TestLogger) Warnf(format string, args ...interface{}) {
|
||||
log.Printf(format+"\n", args...)
|
||||
}
|
||||
func (t *TestLogger) Error(i ...interface{}) {
|
||||
log.Println(i...)
|
||||
}
|
||||
func (t *TestLogger) Errorf(format string, args ...interface{}) {
|
||||
log.Printf(format+"\n", args...)
|
||||
}
|
||||
|
||||
type TestCeodTransactionRequestContext struct {
|
||||
logger *TestLogger
|
||||
cancelChan <-chan struct{}
|
||||
}
|
||||
|
||||
func (t *TestCeodTransactionRequestContext) Log() logging.Logger {
|
||||
return t.logger
|
||||
}
|
||||
func (t *TestCeodTransactionRequestContext) CancelChan() <-chan struct{} {
|
||||
return t.cancelChan
|
||||
}
|
||||
|
||||
func NewCeodTransactionRequestContext(cancelChan <-chan struct{}) model.CeodTransactionRequestContext {
|
||||
return &TestCeodTransactionRequestContext{logger: &SharedTestLogger, cancelChan: cancelChan}
|
||||
}
|
||||
|
||||
func handleTxn[T any](txnChan <-chan model.ITransactionMessage, err error) *T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for msg := range txnChan {
|
||||
if _, ok := msg.(*model.TransactionInProgress); ok {
|
||||
// ignore
|
||||
} else if abortedMsg, ok := msg.(*model.TransactionAborted); ok {
|
||||
panic(abortedMsg.Error)
|
||||
} else if completedMsg, ok := msg.(*model.TransactionCompleted[T]); ok {
|
||||
return &completedMsg.Result
|
||||
} else {
|
||||
panic(fmt.Errorf("Unrecognized message type: %+v", msg))
|
||||
}
|
||||
}
|
||||
panic("Did not receive completed transaction message")
|
||||
}
|
||||
|
||||
func AddSimpleUser() *model.UserWithPassword {
|
||||
username := getNextSimpleUsername()
|
||||
cancelChan := make(chan struct{})
|
||||
defer close(cancelChan)
|
||||
ctx := NewCeodTransactionRequestContext(cancelChan)
|
||||
req := model.AddUserRequest{
|
||||
Uid: username,
|
||||
Cn: "John Doe",
|
||||
GivenName: "John",
|
||||
Sn: "Doe",
|
||||
Program: "MAT/Mathematics Computer Science",
|
||||
Terms: 1,
|
||||
ForwardingAddresses: []string{username + "@" + Cfg.UWDomain},
|
||||
}
|
||||
return handleTxn[model.UserWithPassword](Ceod.AddUser(ctx, &req))
|
||||
}
|
||||
|
||||
func DeleteUser(username string) {
|
||||
cancelChan := make(chan struct{})
|
||||
defer close(cancelChan)
|
||||
ctx := NewCeodTransactionRequestContext(cancelChan)
|
||||
result := *handleTxn[string](Ceod.DeleteUser(ctx, username))
|
||||
if result != "OK" {
|
||||
panic(fmt.Errorf("Result was not 'OK': '%s'", result))
|
||||
}
|
||||
}
|
||||
|
||||
type TestClient struct {
|
||||
User *model.UserWithPassword
|
||||
client http.Client
|
||||
}
|
||||
|
||||
func NewTestClientFromUser(user *model.UserWithPassword) *TestClient {
|
||||
return &TestClient{User: user, client: http.Client{}}
|
||||
}
|
||||
|
||||
func NewTestClient() *TestClient {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &TestClient{
|
||||
User: AddSimpleUser(),
|
||||
client: http.Client{Jar: jar},
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy must be called if the client was created with NewTestClient()
|
||||
func (c *TestClient) Destroy() {
|
||||
DeleteUser(c.User.Uid)
|
||||
}
|
||||
|
||||
func (c *TestClient) request(method, urlPath string, formData url.Values) (*goquery.Document, error) {
|
||||
var bodyReader io.Reader
|
||||
if formData != nil {
|
||||
bodyReader = strings.NewReader(formData.Encode())
|
||||
}
|
||||
r, err := http.NewRequest(method, TestServer.URL+urlPath, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
r.Header.Set("X-CSC-ADFS-Username", c.User.Uid)
|
||||
r.Header.Set("X-CSC-ADFS-FirstName", c.User.GivenName)
|
||||
resp, err := c.client.Do(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
|
||||
}
|
||||
|
||||
func (c *TestClient) Get(urlPath string) (*goquery.Document, error) {
|
||||
return c.request("GET", urlPath, nil)
|
||||
}
|
||||
|
||||
func (c *TestClient) Post(urlPath string, formData url.Values) (*goquery.Document, error) {
|
||||
return c.request("POST", urlPath, formData)
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPwreset(t *testing.T) {
|
||||
client := NewTestClient()
|
||||
defer client.Destroy()
|
||||
doc, err := client.Get("/pwreset")
|
||||
require.Nil(t, err)
|
||||
inputs := doc.Find("form input")
|
||||
csrfInput := inputs.Filter(`[name="_csrf"]`)
|
||||
require.Equal(t, 1, csrfInput.Length())
|
||||
csrfToken, ok := csrfInput.Attr("value")
|
||||
require.True(t, ok)
|
||||
submitInput := inputs.Filter(`[type="submit"]`)
|
||||
require.Equal(t, 1, submitInput.Length())
|
||||
submitInputValue, ok := submitInput.Attr("value")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "Reset my password", submitInputValue)
|
||||
|
||||
defer TestMail.Reset()
|
||||
doc, err = client.Post("/pwreset", url.Values{"_csrf": {csrfToken}})
|
||||
require.Nil(t, err)
|
||||
para := doc.Find("p").First()
|
||||
uwEmail := client.User.ForwardingAddresses[0]
|
||||
require.Contains(
|
||||
t,
|
||||
para.Text(),
|
||||
fmt.Sprintf("A new temporary password was sent to %s.", uwEmail),
|
||||
)
|
||||
|
||||
require.Len(t, TestMail.SentEmails, 1)
|
||||
emailMsg := &TestMail.SentEmails[0]
|
||||
require.Equal(t, "ceod+web@csclub.internal", emailMsg.From)
|
||||
require.Equal(t, []string{uwEmail}, emailMsg.To)
|
||||
matched, err := regexp.Match("(?m)^Subject: Computer Science Club Password Reset$", emailMsg.Msg)
|
||||
require.Nil(t, err)
|
||||
require.True(t, matched)
|
||||
emailBodyStr := string(emailMsg.Msg)
|
||||
require.Contains(t, emailBodyStr, "Your new temporary CSC password is:\n\n")
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"app_url": "http://127.0.0.1",
|
||||
"socket_path": "app.sock",
|
||||
"csc_domain": "csclub.internal",
|
||||
"uw_domain": "uwaterloo.internal",
|
||||
"dev": true
|
||||
}
|
Loading…
Reference in New Issue