Giter Club home page Giter Club logo

flask-ldap-login's Introduction

flask-ldap

Flask ldap login is designed to work on top of an existing application.

It will:

  • Connect to an ldap server
  • Lookup ldap users using a direct bind or bind/search method
  • Store the ldap user into your server's DB
  • Integrate ldap into an existing web application

It will not:

  • Provide login_required or any other route decorators
  • Store the active user’s ID in the session. Use an existing framework like Flask-Login for this task.

Examples

Configuring your Application

The most important part of an application that uses flask-ldap-Login is the LDAPLoginManager class. You should create one for your application somewhere in your code, like this:

ldap_mgr = LDAPLoginManager()

The login manager contains the code that lets your application and ldap work together such as how to load a user from an ldap server, and how to store the user into the application's database.

Once the actual application object has been created, you can configure it for login with:

login_manager.init_app(app)

Testing your Configuration

Run the flask-ldap-login-check command against your app to test that it can successully connect to your ldap server.

flask-ldap-login-check examples.direct_bind:app --username 'me' --password 'super secret'

How it Works

save_user callback

You will need to provide a save_user callback. This callback is used store any users looked up in the ldap diectory into your database. For example: Callback must return a user object or None if the user can not be created.

@ldap_mgr.save_user
def save_user(username, userdata):
    user = User.get(username=username)
    if user is None:
        user = create_user(username, userdata)
    else:
        user.update(userdata)
        user.save()

    return user

LDAPLoginForm form

The LDAPLoginForm is provided to you for your convinience. Once validated the form will contain a valid form.user object which you can use in your application. In this example, the user object is logged in using the login_user from The Flask-Login module:

@app.route('/login', methods=['GET', 'POST'])
def ldap_login():

    form = LDAPLoginForm(request.form)

    if form.validate_on_submit():
        login_user(form.user, remember=True)
        print "Valid"
        return redirect('/')
    else:
        print "Invalid"
    return render_template('login.html', form=form)

Configuration Variables

To set the flask-ldap-login config variables

update the application like:

app.config.update(LDAP={'URI': ..., })

URI:

Start by setting URI to point to your server. The value of this setting can be anything that your LDAP library supports. For instance, openldap may allow you to give a comma- or space-separated list of URIs to try in sequence.

BIND_DN:

The distinguished name to use when binding to the LDAP server (with BIND_AUTH). Use the empty string (the default) for an anonymous bind.

BIND_AUTH

The password to use with BIND_DN

USER_SEARCH

An dict that will locate a user in the directory. The dict object may contain base (required), filter (required) and scope (optional)

  • base: The base DN to search
  • filter: Should contain the placeholder %(username)s for the username.
  • scope: TODO: document

e.g.:

{'base': 'dc=continuum,dc=io', 'filter': 'uid=%(username)s'}

KEY_MAP:

This is a dict mapping application context to ldap. An application may expect user data to be consistant and not all ldap setups use the same configuration:

'application_key': 'ldap_key'

For example:

KEY_MAP={'name':'cn', 'company': 'o', 'email': 'mail'}

START_TLS

If True, each connection to the LDAP server will call start_tls_s() to enable TLS encryption over the standard LDAP port. There are a number of configuration options that can be given to OPTIONS that affect the TLS connection. For example, OPT_X_TLS_REQUIRE_CERT can be set to OPT_X_TLS_NEVER to disable certificate verification, perhaps to allow self-signed certificates.

OPTIONS

This stores ldap specific options eg:

LDAP={ 'OPTIONS': { 'OPT_PROTOCOL_VERSION': 3,
                    'OPT_X_TLS_REQUIRE_CERT': 'OPT_X_TLS_NEVER'
                  }
     }

TLS (secure LDAP)

To enable a secure TLS connection you must set START_TLS to True. There are a number of configuration options that can be given to OPTIONS that affect the TLS connection. For example, OPT_X_TLS_REQUIRE_CERT OPT_X_TLS_NEVER to disable certificate verification, perhaps to allow self-signed certificates.

LDAP={ 'START_TLS': True,
       'OPTIONS': { 'OPT_PROTOCOL_VERSION': 3,
                    'OPT_X_TLS_DEMAND', True,
                    'OPT_X_TLS_REQUIRE_CERT': 'OPT_X_TLS_NEVER',
                    'OPT_X_TLS_CACERTFILE', '/path/to/certfile')

                  }
     }

flask-ldap-login's People

Contributors

abarto avatar back2basics avatar bkreider avatar dsludwig avatar jwg4 avatar khorsmann avatar srossross avatar starcruiseromega avatar tswicegood avatar vshevchenko-anaconda avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

flask-ldap-login's Issues

Please log invalid password and invalid users separately

From @bkreider on October 23, 2015 22:18

Needs an update to flask-login-ldap here: https://github.com/ContinuumIO/flask-ldap-login/blob/master/flask_ldap_login/__init__.py#L157-L174

        for search in user_search:
            base = search['base']
            filt = search['filter'] % ctx
            scope = search.get('scope', ldap.SCOPE_SUBTREE)
            log.debug("Search for base=%s filter=%s" % (base, filt))
            results = self.conn.search_s(base, scope, filt, attrlist=self.attrlist)
            if results:
                log.debug("User with DN=%s found" % results[0][0])
                try:
                    self.conn.simple_bind_s(results[0][0], password)
                except ldap.INVALID_CREDENTIALS:
                    self.conn.simple_bind_s(user, bind_auth)
                    log.debug("Username/password mismatch, continue search...")
                    results = None
                    continue
                else:
                    log.debug("Username/password OK")
                    break
           else:   <<-------- this condition isn't tracked - note you can't use an else  here because it is a loop anyway, but track the difference between "bad auth" and "bad user".  

The if results block doesn't log when no user is found...only when there are invalid credentials. I just waited a bunch of time trying to figure out what i was doing wrong and realized the username was slightly wrong. I suggest tracking when no results are found.

Copied from original issue: ContinuumIO/wakari-server#492

Duplicate flash messages when logging in?

Both line 235 and line 246 provide the same message to the user. Are those different paths that require different information?

Looking through that code path, it appears that ldap.INVALID_CREDENTIALS is being thrown but caught which would lead to the line 246 state. I feel like I'm missing something -- probably inside the format_search and why it would be called with no data.

Allow logging in check.py

It's really hard to debug why a configuration is failing without a way to log at the debug level or even with trace_level=3 set on the ldap object.

Prompt user for password instead of requiring it in plain text as input

We use flask-ldap-login-check to test the LDAP config for both Repo and AE-N.

Both require the user's password be given in plain text. Looks like this has been fixed in latest master with this commit so password isn't required input

Do we need a new release for this feature to be available with AE-Repo and AE-N?
Is it coming soon?

Thanks!

How to specify the port? In the uri?

Can you document here and in AEN and AER how to specify the port? I would assume that's just tacked onto the URI key, but I was having problems, so it would be good to have a real answer.

[LoginForm] Provide invalid password callback

ACTUAL:
There isn't an easy way to react to a failed login due to an invalid password. This is important for security. We want to lock a user's account after 5 invalid password attempts but don't have a way to react to a failed login attempt.

EXPECTED:
Easy way to react to failed login due to invalid password.

PROPOSED FIX:
Update LoginForm.validate to execute a callback when the password verification fails.
https://github.com/lingthio/Flask-User/blob/a379fa0a281789618c484b459cb41236779b95b1/flask_user/forms.py#L201-L203

unicode/str

hello,

in method connect():

for opt, value in self.config.get('OPTIONS', {}).items():
    if isinstance(opt, str):
        opt = getattr(ldap, opt)
    try:
        if isinstance(value, str):
            value = getattr(ldap, value)
    except AttributeError:
        pass

in python2, the checks vs 'str' are not very robust. the caller could pass a unicode string like u"OPT_PROTOCOL_VERSION". In current code this would trigger an uncaught exception (because python-ldap only accepts integers)

(I faced this issue with a wiki - realms on github. Pretty hard to trace back to LDAP as of course the only symptom was a 500 HTTP error. In the realms wiki the parameters come from a JSON file, that's of course parsed to unicode)

Bug in how ldap config is being applied

See issue #447 and this comment for the problem.

Saw this issue with Anaconda Repo for another client.

@dsludwig - shall I assign this to you since you've identified the source of the bug?

Copying @dsludwig's comment below:
This works (see http://python-ldap.cvs.sourceforge.net/viewvc/python-ldap/python-ldap/Demo/initialize.py?revision=1.9&view=markup):

import ldap
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, '/etc/ipa/ca.crt')
l = ldap.initialize('ldaps://ldap.example.com')
l.simple_bind_s('', '')

But this fails (see https://github.com/ContinuumIO/flask-ldap-login/blob/master/flask_ldap_login/__init__.py#L238-L247):

import ldap
l = ldap.initialize('ldaps://ldap.example.com')
l.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
l.set_option(ldap.OPT_X_TLS_CACERTFILE, '/etc/ipa/ca.crt')
l.simple_bind_s('', '')

Cannot install using pip on Python 3

Complete output from command python setup.py egg_info:
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "C:\Users\IamAnUser\AppData\Local\Temp\pycharm-packaging\python-ldap\setup.py", line 53
    print name + ': ' + cfg.get('_ldap', name)
             ^
SyntaxError: invalid syntax

'module' object has no attribute 'TextField'

Traceback (most recent call last):
  File "runserver.py", line 5, in <module>
    from portalapp import app
  File "/Users/myuser/myapp/__init__.py", line 4, in <module>
    from flask_ldap_login import LDAPLoginManager
  File "/Users/myuser/myapp/lib/python2.7/site-packages/flask_ldap_login/__init__.py", line 216, in <module>
    class LDAPLoginForm(wtf.Form):
  File "/Users/myuser/myapp/lib/python2.7/site-packages/flask_ldap_login/__init__.py", line 224, in LDAPLoginForm
    username = wtf.TextField('Username', validators=[wtf.Required()])
AttributeError: 'module' object has no attribute 'TextField'

Active Directory Bug

Active Directory uses the cn (full name) of the user to authenticate. If the username has spaces in it the authentication fails and the users gets a 404 error page. If the cn is changed to have no spaces, it authenticates fine.
Current: Authentication fails when --username contains spaces
Expected: Authentication does not fail when using spaces.

cn contains spaces: Joel Hull
screen shot 2014-08-29 at 16 04 50

cn contains no spaces: jhull
screen shot 2014-08-29 at 16 07 59

LDAP: KEY_MAP existence causing mixed results

From @amccarty on October 5, 2015 20:52

I'm getting difference results when I use a KEY_MAP vs when I don't.

In the config below (it works, so feel free to test with it) I'm mapping email and name. The database results are below:

{
    "accounts":"wk_server.plugins.accounts.ldap2",
    "LDAP" : {
        "URI": "ldap://openldap.testcio.com",
        "BIND_DN": "cn=Bob Jones,ou=Users,DC=testcio,DC=com",
        "BIND_AUTH": "p@ssw0rd",
        "USER_SEARCH": {"base": "DC=testcio,DC=com",
                        "filter": "(| (& (ou=Payroll) (uid=%(username)s)) (& (ou=Janitorial) (uid=%(username)s)))"
                        },
        "KEY_MAP": {"email": "mail",
                    "name": "cn" 
                }
    }
}

Resulting DB entry:

{ "_id" : ObjectId("5612e02de138230dabf14cd1"),
 "username" : "NewportC",
 "_username" : "newportc",
 "name" : "Chok Newport",
 "is_active" : true,
 "attrs" : {
    "username" : "NewportC"
  }, 
"time" : {
    "last_seen" : ISODate("2015-10-05T20:40:22.912Z"),
    "modified" : ISODate("2015-10-05T20:40:22.899Z"),
    "created" : ISODate("2015-10-05T20:40:13.862Z")
 },
"password" : null, 
"email" : "[email protected]" 
}

In this config, I'm not mapping. The database results are below:

{
    "accounts":"wk_server.plugins.accounts.ldap2",
    "LDAP" : {
        "URI": "ldap://openldap.testcio.com",
        "BIND_DN": "cn=Bob Jones,ou=Users,DC=testcio,DC=com",
        "BIND_AUTH": "p@ssw0rd",
        "USER_SEARCH": {"base": "DC=testcio,DC=com",
                        "filter": "(| (& (ou=Payroll) (uid=%(username)s)) (& (ou=Janitorial) (uid=%(username)s)))"
                        }
    }
}

Resulting DB entry:

{ "_id" : ObjectId("5612df5ce138230d65c7674c"), 
"cn" : "Chok Newport", 
"objectClass" : [ 
   "top",  
   "person",  
   "organizationalPerson",  
   "inetOrgPerson" 
], 
"secretary" : "cn=Gee Serbus,ou=Management,dc=testcio,dc=com", 
"manager" : "cn=Mehmud Jarnak,ou=Administrative,dc=testcio,dc=com", 
"attrs" : {  
   "username" : "NewportC" 
}, 
"uid" : "NewportC", 
"employeeType" : "Employee", 
"title" : "Junior Janitorial Grunt", 
"facsimileTelephoneNumber" : "+1 415 245-1438", 
"mail" : "[email protected]", 
"postalAddress" : "Janitorial$Orem", 
"email" : null, 
"username" : "NewportC",
 "departmentNumber" : "2209", 
"_username" : "newportc", 
"description" : "This is Chok Newport's description", 
"is_active" : true, 
"password" : null, 
"pager" : "+1 415 144-7812", 
"homePhone" : "+1 415 412-9970",
 "telephoneNumber" : "+1 415 773-1661", 
"mobile" : "+1 415 965-5956", 
"roomNumber" : "9087", 
"l" : "Orem", 
"carLicense" : "76HIQX", 
"sn" : "Newport", 
"time" : { 
   "last_seen" : ISODate("2015-10-05T20:36:44.096Z"),  
   "modified" : ISODate("2015-10-05T20:36:44.047Z"),  
   "created" : ISODate("2015-10-05T20:36:44.047Z") 
},  
"ou" : "Janitorial",  
"givenName" : "Chok",  
"initials" : "C. N." 
}
`

_Copied from original issue: ContinuumIO/wakari-server#487_

Unable to escape parentheses in LDAP search filter

We have a customer who has Active Directory groups with parentheses in group names. For some reason, regular "" escapes don't work, nor do unicode escapes, or any amount of quoting, bracketing, etc. that I've tried. This affects both Wakari and Anaconda Server. Here's a test example (with working credentials):

{
    "accounts":"wk_server.plugins.accounts.ldap2",
    "LDAP" : {
        "URI": "ldap://dc1.testcio.com",
        "BIND_DN": "CN=Slim Pickens,OU=Austin,DC=testcio,DC=com",
        "BIND_AUTH": "C0nt1nuum!",
        "USER_SEARCH": {"base": "OU=Austin,DC=testcio,DC=com",
                        "filter": "(&(objectCategory=Person)(memberOf=CN=Testers (1),CN=Users,dc=testcio,dc=com)(sAMAccountName=%(username)s))"
        }
    }
}

Output from flask-ldap-login-check:

-bash-4.1$ /opt/wakari/wakari-server/bin/flask-ldap-login-check wk_server.wsgi:app -u slimp -p C0nt1nuum!
Loading config from /opt/wakari/wakari-server/etc/wakari/config.json
Loading config from /opt/wakari/wakari-server/etc/wakari/wk-server-config.json
11253 | 2015-12-14 14:43:22.405 INFO: Initialize Wakari LDAP [__init__.py:50]
11253 | 2015-12-14 14:43:22.410 INFO: Days left in license: 143 [wsgi.py:12]
11253 | 2015-12-14 14:43:22.410 INFO: Wakari Server Ready [wsgi.py:12]
11253 | 2015-12-14 14:43:22.411 DEBUG: Connecting to ldap server ldap://dc1.testcio.com [__init__.py:219]
11253 | 2015-12-14 14:43:22.412 DEBUG: Performing bind/search [__init__.py:154]
11253 | 2015-12-14 14:43:22.412 DEBUG: Binding with the BIND_DN CN=Slim Pickens,OU=Austin,DC=testcio,DC=com [__init__.py:162]
11253 | 2015-12-14 14:43:22.548 DEBUG: Search for base=OU=Austin,DC=testcio,DC=com filter=(&(objectCategory=Person)(memberOf=CN=Testers (1),CN=Users,dc=testcio,dc=com)(sAMAccountName=slimp)) [__init__.py:176]
Traceback (most recent call last):
  File "/opt/wakari/wakari-server/bin/flask-ldap-login-check", line 6, in <module>
    sys.exit(main())
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/flask_ldap_login/check.py", line 29, in main
    userdata = app.ldap_login_manager.ldap_login(args.username, args.password)
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/flask_ldap_login/__init__.py", line 246, in ldap_login
    result = self.bind_search(username, password)
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/flask_ldap_login/__init__.py", line 177, in bind_search
    results = self.conn.search_s(base, scope, filt, attrlist=self.attrlist)
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/ldap/ldapobject.py", line 552, in search_s
    return self.search_ext_s(base,scope,filterstr,attrlist,attrsonly,None,None,timeout=self.timeout)
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/ldap/ldapobject.py", line 545, in search_ext_s
    msgid = self.search_ext(base,scope,filterstr,attrlist,attrsonly,serverctrls,clientctrls,timeout,sizelimit)
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/ldap/ldapobject.py", line 541, in search_ext
    timeout,sizelimit,
  File "/opt/wakari/wakari-server/lib/python2.7/site-packages/ldap/ldapobject.py", line 99, in _ldap_call
    result = func(*args,**kwargs)
ldap.FILTER_ERROR: {'desc': 'Bad search filter'}

NOTE: The slimp user is also member of the "Testers" group, so to demonstrate a working example you can substitute "Tester" for "Tester (1)" in the config above.

Successful output via python and ldapsearch:

Python:

>>> import ldap
>>> 
>>> con = ldap.initialize("ldap://dc1.testcio.com")
>>> dn = "CN=Slim Pickens,OU=Austin,DC=testcio,DC=com"
>>> pw = "C0nt1nuum!"
>>> base_dn = "OU=Austin,DC=testcio,DC=com"
>>> filter = "(&(objectCategory=Person)(memberOf=CN=Testers \(1\),CN=Users,dc=testcio,dc=com))"
>>> attrs = ['sAMAccountName']
>>> con.simple_bind_s( dn, pw )
(97, [], 1, [])
>>> con.search_s( base_dn, ldap.SCOPE_SUBTREE, filter, attrs )
[('CN=Slim Pickens,OU=Austin,DC=testcio,DC=com', {'sAMAccountName': ['slimp']})]

Ldapsearch:

ldapsearch -x  -b "DC=testcio,DC=com" -H ldap://dc1.testcio.com -D "CN=Slim Pickens,OU=Austin,DC=testcio,DC=com"  -W "(&(objectCategory=Person)(memberO
f=CN=Testers \(1\),CN=Users,dc=testcio,dc=com))" | grep -i samaccountname 
Enter LDAP Password: 
sAMAccountName: slimp

LDAP over TLS issues with wakari-4.0

I’m trying to solve a client’s issue with LDAP with TLS. I have a test wakari 4.0 setup, it has version 0.3.1:

$ conda list -p /opt/wakari/wakari-server flask-ldap-login
# packages in environment at /opt/wakari/wakari-server:
#
flask-ldap-login          0.3.1                    py27_0    <unknown>

In the tags for this github repo

  1. I only see version 0.3.2 and 0.3.0 - where is version 0.3.1?
  2. Also looks like connect function doesn't yet have this fix to load the 'OPT_X_TLS_NEWCTX' first. Will we have a new release with this fix soon?

Here's the client question:

I should point out that during the first POC phase we had several months ago, I ran into the same exact issue, and forwarded the information to continuum support..

I was able to resove the problem by using the options:


"OPTIONS": {"OPT_X_TLS_REQUIRE_CERT": "OPT_X_TLS_DEMAND",
"OPT_X_TLS_CACERTFILE": "/etc/ldap/Certs/LDAPCACert.crt"
}

in the wk-server-config.json file and the modifying the source code for the login procedure as follows:

In the /opt/wakari/wakari-server/lib/python2.7/site-packages/flask_ldap_login/__init__.py file

I modified the connect() functions as listed below:

def connect(self):
'initialize ldap connection and set options'
log.debug("Connecting to ldap server %s" % self.config['URI'])
self.conn = ldap.initialize(self.config['URI'])

ldapOptionsSpecified = False
for opt, value in self.config.get('OPTIONS', {}).items():

#For self signed Certificates in openLDAP, we need to specify:
# Force cert validation
#conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT,ldap.OPT_X_TLS_DEMAND)
# Set path name of file containing all trusted CA certificates
#conn.set_option(ldap.OPT_X_TLS_CACERTFILE,CACERTFILE)

#And finally, after having provied the CA cert path,
# Force libldap to create a new SSL context
#conn.set_option(ldap.OPT_X_TLS_NEWCTX,ldap.OPT_X_TLS_DEMAND)
#Note that ldap.OPT_X_TLS_NEWCTX needs to be the last option that is set!


#Using user provided LDAP options
ldapOptionsSpecified = True
log.debug("Setting LDAP option: "+ str(opt) +", " + str(value))

if isinstance(opt, (str, unicode)):
opt = getattr(ldap, opt)
log.debug("LDAP option integer is: "+ str(opt) )

try:
if isinstance(value, (str, unicode)):
value = getattr(ldap, value)
log.debug("LDAP option value is: "+ str(value) )
except AttributeError:
pass
log.debug("Set LDAP option: "+ str(opt) +", " + str(value))
self.conn.set_option(opt, value)

# Force libldap to create a new SSL context if specific LDAP options were provided
if(ldapOptionsSpecified):
log.debug("Creating new SSL context.")
self.conn.set_option(ldap.OPT_X_TLS_NEWCTX,ldap.OPT_ON)

if self.config.get('START_TLS'):
log.debug("Starting TLS")
self.conn.start_tls_s()

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.