OpenStax Accounts is a centralized User Account services provider for various OpenStax products, including:
- Authentication and authorization
- Profile/personal data
- Email notifications
- OAuth Application privileges
It uses OAuth mechanisms and API keys to provide these services to OpenStax products and their users.
Accounts requires the repeatable read isolation level to work properly. If using PostgreSQL, add the following to your postgresql.conf
:
default_transaction_isolation = 'repeatable read'
- Different ways to create a user account
- What happens during sign up Which records need to be created and why.
- Logging in How we check a user's credentials.
- GDPR For compliance with the General Data Protection Regulation (a regulation in the European Union to protect their citizens' data and privacy).
- Special Parameters The app behavior changes depending on the value of these parameters.
- Salesforce
Accounts can be run as a normal Rails app on your machine or in a Docker container.
# build the image
$> docker-compose build
# run it; Accounts available at localhost:2999
$> docker-compose up -d
# run it allowing for debugging
$> docker-compose up -d && docker attach accounts_app_1
If you don't have postgresql already installed, on Mac:
$ brew install postgresql
$ brew services start postgresql
$ psql postgres
CREATE ROLE ox_accounts WITH LOGIN;
ALTER USER ox_accounts WITH SUPERUSER;
\q
First, ensure you have a Ruby version manager installed, such as rbenv or RVM to manage your ruby versions. Then, install the Ruby version specified in the .ruby-version
file (2.3.3 at the time of this writing, or above).
To start running Accounts in a development environment, clone the repository and then run:
$ bundle install --without production
Just like with any Rails app, you need to create, migrate, and then seed the database with some default records:
$ rake db:create db:setup
Before starting the server, you'll need to create a .env
file based off of the example:
$ cp .env.example .env
Now you can run:
$ rails server
which will start Accounts up on port 2999. Visit http://localhost:2999.
Accounts in production runs background jobs using delayed_job
.
In the development environment, however, background jobs are run "inline", i.e. in the foreground.
To actually run these jobs in the background in the development environment,
set the environment variable USE_REAL_BACKGROUND_JOBS=true
in your .env
file
and then start the delayed_job
daemon:
bin/rake jobs:work
Using Docker, you can
$> docker-compose run --rm app /bin/bash
to drop into a container with everything installed. Then before your first test run
$ /code> rake db:create
$ /code> rake db:migrate # run again if add migrations later
Then to run tests...
$ /code> rspec
$ /code> rspec ./spec/features
$ /code> rspec ./spec/some/specific_spec.rb
$ /code> rspec ./spec/some/specific_spec.rb:42 # the spec at line 42
Or if you want to install directly on your machine...
Specs require phantomjs. On Mac:
$ brew install phantomjs
To run specs,
$ rake
When running feature specs, the default behavior is for exceptions to be rescued and nice error pages to be shown. This can make debugging difficult if you're not expecting an error. To not rescue exceptions, do:
$ RAISE=true rspec
If you encounter issues running features specs, check the version of chromedriver you have installed. Version 2.38 is known to work.
Set DEBUGGER=byebug
to use byebug (e.g. in .env
), otherwise defaults to the VS Code debugger.
The most straightforward way to sign up is by visiting the signup_path
without any parameters in the URL and without any state in the session.
authenticate_user!
calls use_signed_params
which is used to either 1) automatically log in users or 2) prefill/preselect their information in the sign up forms like their role
and email
address.
This feature is primarily used for logging in students via an LMS (Learning Management System). The LMS sends a student's info to Tutor and Tutor signs the request and sends it to Accounts
An OAuth application consumer may send users to /oauth/authorize
url endpoint (along with the client_id
and other params specified in the OAuth protocol definition). When this happens, assuming there's no currently logged in user to Accounts, user is redirected to the login page. Once logged in, the user is redirected back to the application consumer. Note that for trusted oauth_applications
, we skip authorization of the application consumer (see config/initializers/doorkeeper.rb under skip_authorization).
An OAuth (doorkeeper) application may make a request to an API endpoint (POST /user/find-or-create
) to create users.
See import_users.rake.
Which records need to be created and why.
- A
User
record, of course, needs to be created. But this record doesn't contain the authentication credentials. It is the main model for users—storesuuid
,first_name
,last_name
,username
, etc.— but everything else is stored via associated models. - An
Authentication
record. This model/record stores the different ways that a user may login, for example, using Facebook, Google, or using email and password. Having a separate model/record for this makes it easier to add new ways of logging in or signing up. - An
Identity
record. Essentially, this model stores the password for any givenAuthentication
and provides a way to check against a user-submitted password during login (calling the methodauthenticate
).Authentication
s (and thereforeUser
s) that don't have a password set up will not have anIdentity
. - An
ApplicationUser
if the user is signing up as they authorize a doorkeeper/OAuth application at the same time basically. See config/initializers/doorkeeper_models.rb, callsFindOrCreateApplicationUser
onbefore_create
. Also, if the user account is being created by an application via the api (/user/find-or-create
) anApplicationUser
is created.FindOrCreateUser
callsFindOrCreateApplicationUser
. This is in order to associate a user with an application. An application may only handle its own users, unless it's one of our own, trusted, applications. - A
ContactInfo
record which essentially stores email addresses for users.
How we check a user's credentials.
Under the hood, we use BCrypt
's authenticate
method to safely compare users' provided password against the password_digest
we've stored on sign up.
One thing we do differently from what is advised as the most secure way of authenticating users is: we let our users know whether they've just entered the wrong password for an account which in fact does exist in our database, or if there is not a (verified) account associated with the provided email or username. This is a tradeoff we make in order to be more friendly with our users but at the same time, we do have rate limiting in place so the security risk is minimal.
There is a feature that allows authenticating against a password which was created in CNX—which is a web application we own and use for editing our books' content. See app/models/identity to see how we do this.
Accounts is able to run with all URLs using an /accounts
path prefix. This lets us put Accounts under a Cloudfront distribution and route
all /accounts/*
requests to it. Test this out by adding /accounts
to the start of any page's path -- navigating from that point forward
should keep you in an /accounts
path prefix.
If for some reason Accounts ever causes you to leave the /accounts
prefix and just return to normal routes, this will be a problem when
Accounts is deployed to Cloudfront, because Cloudfront won't route that request to Accounts. We added some middleware to Accounts that will
freak out if this happens. You can run the rails server with a SIMULATE_CLOUDFRONT=true
environment variable and the server will raise an exception if it ever receives a URL without the /accounts
prefix. This is useful for clicking around and making sure we have accounted for all of the routes.
You can also use this environment variable when running tests, but note that the expectations on paths ("expect page to have path blah") have
not been updated to expect the /accounts
prefix.
A salesforce user must be signed in through the admin console for the Salesforce stuff to work — Salesforce > Setup > Set Salesforce User
For logged-in users, Accounts reports GDPR status in the /api/user
endpoint via a is_not_gdpr_location
flag. When this value is true
, the user is not in a GDPR location. Otherwise (false
or nil
or not in the response), the user may be in a GDPR location. To test this functionality in development, you can specify an IP address via the IP_ADDRESS_FOR_GDPR
environment variable, which will override the normal localhost request IP address.
The app behavior changes depending on the value of these parameters.
- OAuth requests that arrive with query param
go=signup
will skip log in and go straight to signup. - OAuth requests that arrive with query param
go=student_signup
will skip to signup and cause the signup form to have the "student" role.
Short for "signed parameters", requests that arrive with a valid sp
parameter may force Accounts to automatically log in a user with the given ID ("valid" meaning signed by a Doorkeeper::Application
configured in Accounts). Or, if no user found by the uuid
parameter, then the signup form is pre-populated with the rest of the "signed parameters."
When a request comes with both signup_at
and client_id
parameters in the login page, the Sign up here link points to signup_at
which has to listed as a callback urls in the client (OAuth) application with ID equal to client_id
.
For example: https://dev.accounts.openstax.org/login?signup_at=https://tutor-dev.openstax.org/signup&client_id=1234
Short for r
edirect parameter, if present and trusted, we store it in order to redirect users back to the OpenStax app they came from – at the end of the login/signup process. See save_redirect which happens as a before_action
in all controllers.
Also, note that OSWeb/the CMS may use the next
parameter instead of r
(link) but it rewrites it as r
for usage in Accounts.
Part of the OAuth protocol, we also take advantage of its presence in the Referrer when users wish to exit Accounts and go back to the OAuth app they came from. See the controller action exit_accounts
here.
These are things that are specific to Accounts and/or will help you learn and understand the codebase better.
Lev, short for Levitate, is a gem developed by us to facilitate writing clean, modular, testable business logic encapsulated in a database transaction.
Practically, they process user-submitted forms. For example:
class FindUser
lev_handler
paramify :login do # available as `login_params`
attribute :username_or_email, type: String
end
protected
def authorized?
# Check permissions for [action] by [current user]
end
def handle
# Do the work.
user = User.where(login_params).first
# Add outputs to `outputs` (a `Hashie::Mash` object).
outputs.user = user
outputs[:foo] = 'bar'
# Add errors to the `errors` object, if any.
errors.add(true, code: :foo)
end
end
We use them in all controllers with handle_with
which takes lambdas in success
and failure
keys, like so:
handle_with(
success: lambda { do_something },
failure: lambda { do_something_else }
)
Inside of the context of success and failure lambdas, is a @handler_result
variable which contains outputs
set inside of the handler (like outputs.user = user
), if any, or an errors
object, if any, which can be populated with fatal_error
or transfer_errors_from
.
Lev Handlers must implement two instance methods:
handle
, which takes no arguments and does the work the handler is charged withauthorized?
, which returns true if and only if the caller is authorized to do what the handler is charged with
Handlers may...
- Implement the
setup
instance method which runs beforeauthorized?
andhandle
. This method can do anything, and will likely include setting up some instance objects based on the params. - call the class method
paramify
to declare, cast, and validate parts of the params hash.
See https://github.com/lml/lev#handlers for more info.
Lev's Routines are pieces of code that have all the responsibility for making one thing (one use case) happen, (e.g. "add an email to a user", "register a student to a class", etc). Lev Handlers and Routines are very similar because all Handlers are Routines.
class MyRoutine
lev_routine
protected
def exec(foo, options={})
fatal_error(code: :some_code_symbol) if foo.nil?
outputs[:foo] = foo * 2
outputs[:bar] = foo * 3
end
end
See https://github.com/lml/lev#routines for more info.
When someone signs up, especially if they are Educators, we want to keep track of whether or not they have already adopted (started using) OpenStax textbooks or software. Of course, we want them to do so, and so we use Salesforce mainly to schedule marketing emails (aka. the newsletter) as well as to store their information in order to verify them as actual Educators – members of a school or homeschool teachers, librarians, etc.
Keeping the above in mind will help you make sense of the signup form and related business logic such as PushSalesforceLead.
In short, new users become sales leads in Salesforce.
Additional documentation can be found in the Accounts Wiki